feat(app): add desktop HTTP bridge for online services

This commit is contained in:
Danila Poyarkov 2026-06-30 16:39:41 +03:00
parent 04ad2db00b
commit 8cefbedd50
26 changed files with 1322 additions and 322 deletions

View file

@ -145,6 +145,7 @@
"svg2pdf.js": "^2.7.0",
"svgpath": "^2.6.0",
"twirlwind": "^0.3.0",
"unifont": "^0.7.4",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3",
},
"devDependencies": {
@ -1531,6 +1532,8 @@
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@ -1567,6 +1570,8 @@
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detective-amd": ["detective-amd@6.1.0", "", { "dependencies": { "ast-module-types": "^6.0.1", "escodegen": "^2.1.0", "get-amd-module-type": "^6.0.2", "node-source-walk": "^7.0.1" }, "bin": { "detective-amd": "bin/cli.js" } }, "sha512-fmI6LGMvotqd49QaA3ZYw+q0aGp2yXmMjzIuY6fH9j9YFIXY/73yDhMwhX9cPbhWd+AH06NH1Di/LKOuCH0Ubg=="],
@ -2139,6 +2144,8 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@ -2271,6 +2278,8 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="],
@ -2291,6 +2300,8 @@
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
@ -2771,6 +2782,8 @@
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],

209
desktop/Cargo.lock generated
View file

@ -440,6 +440,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@ -1366,8 +1372,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@ -1377,9 +1385,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -1545,7 +1555,7 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.13.1"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
@ -1682,6 +1692,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@ -1862,7 +1873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.13.1",
"hashbrown 0.12.3",
"serde",
]
@ -2168,6 +2179,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@ -2639,10 +2656,11 @@ dependencies = [
[[package]]
name = "open_pencil"
version = "0.13.1"
version = "0.13.2"
dependencies = [
"fix-path-env",
"font-kit",
"reqwest 0.12.28",
"serde",
"serde_json",
"tauri",
@ -3109,6 +3127,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.44"
@ -3149,6 +3222,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@ -3169,6 +3252,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@ -3187,6 +3280,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@ -3289,6 +3391,44 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@ -3366,6 +3506,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
@ -3420,6 +3566,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@ -3467,6 +3614,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@ -3688,6 +3841,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.17.0"
@ -4114,7 +4279,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@ -4341,7 +4506,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
@ -4564,6 +4729,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.49.0"
@ -5144,6 +5324,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webkit2gtk"
version = "2.0.2"
@ -5197,6 +5387,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"

View file

@ -23,6 +23,7 @@ tauri = { version = "2", features = ["devtools"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tauri-plugin-dialog = "2.6.0"
tauri-plugin-fs = { version = "2.4.5", features = ["watch"] }
tauri-plugin-shell = "2"

84
desktop/src/http.rs Normal file
View file

@ -0,0 +1,84 @@
use reqwest::{
header::{HeaderMap, HeaderName, HeaderValue},
Method,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Deserialize, Serialize)]
pub struct ProxyHttpHeader {
name: String,
value: String,
}
#[derive(Deserialize)]
pub struct ProxyHttpRequest {
url: String,
method: Option<String>,
headers: Option<Vec<ProxyHttpHeader>>,
body: Option<Vec<u8>>,
}
#[derive(Serialize)]
pub struct ProxyHttpResponse {
status: u16,
headers: Vec<ProxyHttpHeader>,
body: Vec<u8>,
}
fn request_headers(headers: Option<Vec<ProxyHttpHeader>>) -> HeaderMap {
let mut mapped = HeaderMap::new();
for header in headers.unwrap_or_default() {
let Ok(header_name) = HeaderName::from_bytes(header.name.as_bytes()) else {
continue;
};
let Ok(header_value) = HeaderValue::from_str(&header.value) else {
continue;
};
mapped.insert(header_name, header_value);
}
mapped
}
fn request_method(method: Option<String>) -> Result<Method, String> {
let raw = method.unwrap_or_else(|| "GET".into());
Method::from_bytes(raw.as_bytes()).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result<ProxyHttpResponse, String> {
let parsed = reqwest::Url::parse(&request.url).map_err(|e| e.to_string())?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err("Only HTTP(S) requests are supported".into());
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|e| e.to_string())?;
let mut builder = client
.request(request_method(request.method)?, parsed)
.headers(request_headers(request.headers));
if let Some(body) = request.body {
builder = builder.body(body);
}
let response = builder.send().await.map_err(|e| e.to_string())?;
let status = response.status().as_u16();
let headers = response
.headers()
.iter()
.filter_map(|(name, value)| {
Some(ProxyHttpHeader {
name: name.as_str().to_owned(),
value: value.to_str().ok()?.to_owned(),
})
})
.collect();
let body = response.bytes().await.map_err(|e| e.to_string())?.to_vec();
Ok(ProxyHttpResponse {
status,
headers,
body,
})
}

View file

@ -1,5 +1,6 @@
mod fig_container;
mod fonts;
mod http;
mod menu;
mod menu_events;
#[cfg(target_os = "macos")]
@ -7,6 +8,7 @@ mod window;
use fig_container::build_fig_file;
use fonts::{list_system_fonts, load_system_font};
use http::proxy_http_request;
use menu::install_app_menu;
use menu_events::handle_menu_event;
use std::{
@ -118,6 +120,7 @@ pub fn run() {
build_fig_file,
list_system_fonts,
load_system_font,
proxy_http_request,
take_pending_open
])
.plugin(tauri_plugin_opener::init())

View file

@ -2,167 +2,169 @@
"name": "@open-pencil/core",
"version": "0.13.2",
"license": "MIT",
"type": "module",
"imports": {
"#core/*": "./src/*"
},
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js",
"import": "./dist/index.js"
},
"./color": {
"types": "./dist/color/index.d.ts",
"default": "./dist/color/index.js",
"import": "./dist/color/index.js"
},
"./text": {
"types": "./dist/text/index.d.ts",
"default": "./dist/text/index.js",
"import": "./dist/text/index.js"
},
"./vector": {
"types": "./dist/vector/index.d.ts",
"default": "./dist/vector/index.js",
"import": "./dist/vector/index.js"
},
"./figma-api": {
"types": "./dist/figma-api/index.d.ts",
"default": "./dist/figma-api/index.js",
"import": "./dist/figma-api/index.js"
},
"./icons": {
"types": "./dist/icons/index.d.ts",
"default": "./dist/icons/index.js",
"import": "./dist/icons/index.js"
},
"./canvas": {
"types": "./dist/canvas/index.d.ts",
"default": "./dist/canvas/index.js",
"import": "./dist/canvas/index.js"
},
"./design-jsx": {
"types": "./dist/design-jsx/index.d.ts",
"default": "./dist/design-jsx/index.js",
"import": "./dist/design-jsx/index.js"
},
"./editor": {
"types": "./dist/editor/index.d.ts",
"default": "./dist/editor/index.js",
"import": "./dist/editor/index.js"
},
"./tools": {
"types": "./dist/tools/index.d.ts",
"default": "./dist/tools/index.js",
"import": "./dist/tools/index.js"
},
"./profiler": {
"types": "./dist/profiler/index.d.ts",
"default": "./dist/profiler/index.js",
"import": "./dist/profiler/index.js"
},
"./rpc": {
"types": "./dist/rpc/index.d.ts",
"default": "./dist/rpc/index.js",
"import": "./dist/rpc/index.js"
},
"./lint": {
"types": "./dist/lint/index.d.ts",
"default": "./dist/lint/index.js",
"import": "./dist/lint/index.js"
},
"./io": {
"types": "./dist/io/index.d.ts",
"default": "./dist/io/index.js",
"import": "./dist/io/index.js"
},
"./io/formats/fig": {
"types": "./dist/io/formats/fig/index.d.ts",
"default": "./dist/io/formats/fig/index.js",
"import": "./dist/io/formats/fig/index.js"
},
"./io/formats/pen": {
"types": "./dist/io/formats/pen/index.d.ts",
"default": "./dist/io/formats/pen/index.js",
"import": "./dist/io/formats/pen/index.js"
},
"./io/formats/jsx": {
"types": "./dist/io/formats/jsx/index.d.ts",
"default": "./dist/io/formats/jsx/index.js",
"import": "./dist/io/formats/jsx/index.js"
},
"./io/formats/raster": {
"types": "./dist/io/formats/raster/index.d.ts",
"default": "./dist/io/formats/raster/index.js",
"import": "./dist/io/formats/raster/index.js"
},
"./io/formats/svg": {
"types": "./dist/io/formats/svg/index.d.ts",
"default": "./dist/io/formats/svg/index.js",
"import": "./dist/io/formats/svg/index.js"
},
"./kiwi": {
"types": "./dist/kiwi/index.d.ts",
"default": "./dist/kiwi/index.js",
"import": "./dist/kiwi/index.js"
},
"./clipboard": {
"types": "./dist/clipboard.d.ts",
"default": "./dist/clipboard.js",
"import": "./dist/clipboard.js"
},
"./constants": {
"types": "./dist/constants.d.ts",
"default": "./dist/constants.js",
"import": "./dist/constants.js"
},
"./random": {
"types": "./dist/random.d.ts",
"default": "./dist/random.js",
"import": "./dist/random.js"
},
"./xpath": {
"types": "./dist/xpath.d.ts",
"default": "./dist/xpath.js",
"import": "./dist/xpath.js"
},
"./canvaskit": {
"types": "./dist/canvaskit.d.ts",
"default": "./dist/canvaskit.js",
"import": "./dist/canvaskit.js"
},
"./layout": {
"types": "./dist/layout.d.ts",
"default": "./dist/layout.js",
"import": "./dist/layout.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"assets"
],
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts",
"prepublishOnly": "bun run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/open-pencil/open-pencil.git",
"directory": "packages/core"
},
"files": [
"dist",
"assets"
],
"type": "module",
"sideEffects": false,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"imports": {
"#core/*": "./src/*"
},
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./color": {
"types": "./dist/color/index.d.ts",
"import": "./dist/color/index.js",
"default": "./dist/color/index.js"
},
"./text": {
"types": "./dist/text/index.d.ts",
"import": "./dist/text/index.js",
"default": "./dist/text/index.js"
},
"./vector": {
"types": "./dist/vector/index.d.ts",
"import": "./dist/vector/index.js",
"default": "./dist/vector/index.js"
},
"./figma-api": {
"types": "./dist/figma-api/index.d.ts",
"import": "./dist/figma-api/index.js",
"default": "./dist/figma-api/index.js"
},
"./icons": {
"types": "./dist/icons/index.d.ts",
"import": "./dist/icons/index.js",
"default": "./dist/icons/index.js"
},
"./canvas": {
"types": "./dist/canvas/index.d.ts",
"import": "./dist/canvas/index.js",
"default": "./dist/canvas/index.js"
},
"./design-jsx": {
"types": "./dist/design-jsx/index.d.ts",
"import": "./dist/design-jsx/index.js",
"default": "./dist/design-jsx/index.js"
},
"./editor": {
"types": "./dist/editor/index.d.ts",
"import": "./dist/editor/index.js",
"default": "./dist/editor/index.js"
},
"./tools": {
"types": "./dist/tools/index.d.ts",
"import": "./dist/tools/index.js",
"default": "./dist/tools/index.js"
},
"./profiler": {
"types": "./dist/profiler/index.d.ts",
"import": "./dist/profiler/index.js",
"default": "./dist/profiler/index.js"
},
"./rpc": {
"types": "./dist/rpc/index.d.ts",
"import": "./dist/rpc/index.js",
"default": "./dist/rpc/index.js"
},
"./lint": {
"types": "./dist/lint/index.d.ts",
"import": "./dist/lint/index.js",
"default": "./dist/lint/index.js"
},
"./io": {
"types": "./dist/io/index.d.ts",
"import": "./dist/io/index.js",
"default": "./dist/io/index.js"
},
"./io/formats/fig": {
"types": "./dist/io/formats/fig/index.d.ts",
"import": "./dist/io/formats/fig/index.js",
"default": "./dist/io/formats/fig/index.js"
},
"./io/formats/pen": {
"types": "./dist/io/formats/pen/index.d.ts",
"import": "./dist/io/formats/pen/index.js",
"default": "./dist/io/formats/pen/index.js"
},
"./io/formats/jsx": {
"types": "./dist/io/formats/jsx/index.d.ts",
"import": "./dist/io/formats/jsx/index.js",
"default": "./dist/io/formats/jsx/index.js"
},
"./io/formats/raster": {
"types": "./dist/io/formats/raster/index.d.ts",
"import": "./dist/io/formats/raster/index.js",
"default": "./dist/io/formats/raster/index.js"
},
"./io/formats/svg": {
"types": "./dist/io/formats/svg/index.d.ts",
"import": "./dist/io/formats/svg/index.js",
"default": "./dist/io/formats/svg/index.js"
},
"./kiwi": {
"types": "./dist/kiwi/index.d.ts",
"import": "./dist/kiwi/index.js",
"default": "./dist/kiwi/index.js"
},
"./clipboard": {
"types": "./dist/clipboard.d.ts",
"import": "./dist/clipboard.js",
"default": "./dist/clipboard.js"
},
"./constants": {
"types": "./dist/constants.d.ts",
"import": "./dist/constants.js",
"default": "./dist/constants.js"
},
"./random": {
"types": "./dist/random.d.ts",
"import": "./dist/random.js",
"default": "./dist/random.js"
},
"./xpath": {
"types": "./dist/xpath.d.ts",
"import": "./dist/xpath.js",
"default": "./dist/xpath.js"
},
"./canvaskit": {
"types": "./dist/canvaskit.d.ts",
"import": "./dist/canvaskit.js",
"default": "./dist/canvaskit.js"
},
"./layout": {
"types": "./dist/layout.d.ts",
"import": "./dist/layout.js",
"default": "./dist/layout.js"
}
},
"publishConfig": {
"access": "public",
"provenance": true
},
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts",
"prepublishOnly": "bun run build"
},
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
"@open-pencil/kiwi": "workspace:*",
"@open-pencil/pen": "workspace:*",
"@open-pencil/scene-graph": "workspace:*",
"@tauri-apps/api": "^2",
"acorn": "^8.16.0",
"canvaskit-wasm": "^0.40.0",
@ -180,9 +182,8 @@
"svg2pdf.js": "^2.7.0",
"svgpath": "^2.6.0",
"twirlwind": "^0.3.0",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3",
"@open-pencil/scene-graph": "workspace:*",
"@open-pencil/pen": "workspace:*"
"unifont": "^0.7.4",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3"
},
"devDependencies": {
"@types/culori": "^4.0.1",

View file

@ -1,12 +1,13 @@
import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm'
import { uniq } from 'es-toolkit/array'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { DEFAULT_FONT_FAMILY, IS_BROWSER, GOOGLE_FONTS_API_KEY } from '#core/constants'
import { DEFAULT_FONT_FAMILY, IS_BROWSER } from '#core/constants'
import { fontFaceRenderFamily, parseFontStyle } from '#core/text/face'
import { fontFallbackEntry } from '#core/text/fallbacks'
import type { FontFallbackScript } from '#core/text/fallbacks'
import { WebFontResolver } from '#core/text/web-fonts'
import type { WebFontFetch, WebFontProviderId } from '#core/text/web-fonts'
export interface FontInfo {
family: string
@ -16,7 +17,7 @@ export interface FontInfo {
}
export type LocalFontAccessState = 'unsupported' | 'prompt' | 'granted' | 'denied'
export type FontFamilySource = 'local' | 'google' | 'bundled'
export type FontFamilySource = 'local' | 'bundled' | 'fallback' | WebFontProviderId
export interface FontFamilyOption {
family: string
@ -136,10 +137,7 @@ export class FontManager {
private localFontAccessState: LocalFontAccessState = IS_BROWSER ? 'prompt' : 'unsupported'
private downloadedFontCache: DownloadedFontCache | null = null
private fallbackUserAgent: string | undefined
private googleFontsCache = new Map<string, Record<string, string>>()
private googleFontsFailed = new Set<string>()
private googleFamiliesCache: string[] | null = null
private googleFamiliesPromise: Promise<string[]> | null = null
private webFonts = new WebFontResolver()
private registeredRenderFamilies = new Set<string>()
private cjkFallbackFamilies: string[] = []
private cjkFallbackPromise: Promise<string[]> | null = null
@ -175,6 +173,18 @@ export class FontManager {
this.fallbackUserAgent = userAgent
}
setOnlineFontProviders(settings: Partial<Record<WebFontProviderId, boolean>>): void {
this.webFonts.setEnabled(settings)
}
setWebFontFetch(fetcher: WebFontFetch | null): void {
this.webFonts.setRemoteFetch(fetcher)
}
enabledOnlineFontProviders(): WebFontProviderId[] {
return this.webFonts.enabledProviders()
}
async loadCachedFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
const cached = await this.readDownloadedFont(family, style)
if (!cached) return null
@ -219,17 +229,30 @@ export class FontManager {
async listFamilyOptions(): Promise<FontFamilyOption[]> {
const fonts = this.localFonts ?? (await this.requestLocalFontAccess())
const googleFamilies = await this.listGoogleFamilies()
const webFontFamilies = await Promise.all(
this.enabledOnlineFontProviders().map(async (provider) => ({
provider,
families: await this.webFonts.listFamilies(provider)
}))
)
const byFamily = new Map<string, FontFamilyOption>()
byFamily.set(DEFAULT_FONT_FAMILY, { family: DEFAULT_FONT_FAMILY, source: 'bundled' })
for (const family of googleFamilies) byFamily.set(family, { family, source: 'google' })
for (const { provider, families } of webFontFamilies) {
for (const family of families) {
if (!byFamily.has(family)) byFamily.set(family, { family, source: provider })
}
}
for (const font of fonts) byFamily.set(font.family, { family: font.family, source: 'local' })
return [...byFamily.values()].sort((a, b) => a.family.localeCompare(b.family))
}
preloadWebFontFamilies(): void {
this.webFonts.preloadFamilies()
}
preloadGoogleFamilies(): void {
if (this.googleFamiliesCache || this.googleFamiliesPromise) return
this.googleFamiliesPromise = this.loadGoogleFamilies()
if (!this.webFonts.enabledProviders().includes('google')) return
void this.webFonts.listFamilies('google')
}
async fetchBundledFont(url: string): Promise<ArrayBuffer | null> {
@ -274,13 +297,15 @@ export class FontManager {
if (typeof fetch !== 'undefined') {
try {
const buffer = await this.fetchGoogleFont(family, style)
const normalized = normalizeFontFamily(family)
const families = normalized === family ? [family] : [family, normalized]
const buffer = await this.webFonts.fetchFont(families, style)
if (buffer) {
await this.writeDownloadedFont(family, style, buffer)
return this.registerAndCache(family, style, buffer)
}
} catch (e) {
console.warn(`Google Fonts fetch failed for "${family}" ${style}:`, e)
console.warn(`Web font fetch failed for "${family}" ${style}:`, e)
}
}
@ -450,79 +475,6 @@ export class FontManager {
}
}
private async retryWithNormalizedFamily(family: string): Promise<Record<string, string> | null> {
const normalized = normalizeFontFamily(family)
if (normalized === family) {
this.googleFontsFailed.add(family)
return null
}
const result = await this.fetchGoogleFontFiles(normalized)
if (result) this.googleFontsCache.set(family, result)
else this.googleFontsFailed.add(family)
return result
}
private async listGoogleFamilies(): Promise<string[]> {
if (this.googleFamiliesCache) return this.googleFamiliesCache
this.googleFamiliesPromise ??= this.loadGoogleFamilies()
return this.googleFamiliesPromise
}
private async loadGoogleFamilies(): Promise<string[]> {
if (typeof fetch === 'undefined') return []
try {
const response = await fetch(
`https://www.googleapis.com/webfonts/v1/webfonts?key=${GOOGLE_FONTS_API_KEY}`
)
if (!response.ok) return []
const data = (await response.json()) as { items?: Array<{ family?: string }> }
const families = uniq(data.items?.flatMap((item) => (item.family ? [item.family] : [])) ?? [])
this.googleFamiliesCache = families.sort()
return this.googleFamiliesCache
} catch {
return []
}
}
private async fetchGoogleFontFiles(family: string): Promise<Record<string, string> | null> {
if (this.googleFontsCache.has(family)) return this.googleFontsCache.get(family) ?? null
if (this.googleFontsFailed.has(family)) return null
const url = `https://www.googleapis.com/webfonts/v1/webfonts?family=${encodeURIComponent(family)}&key=${GOOGLE_FONTS_API_KEY}`
let response: Response
try {
response = await fetch(url)
} catch {
this.googleFontsFailed.add(family)
return null
}
if (!response.ok) return this.retryWithNormalizedFamily(family)
const data = (await response.json()) as {
items?: Array<{ files?: Record<string, string> }>
}
const files = data.items?.[0]?.files
if (!files) return this.retryWithNormalizedFamily(family)
this.googleFontsCache.set(family, files)
return files
}
private async fetchGoogleFont(family: string, style: string): Promise<ArrayBuffer | null> {
const files = await this.fetchGoogleFontFiles(family)
if (!files) return null
const variant = styleToVariant(style)
const ttfUrl = files[variant] ?? files['regular']
if (!ttfUrl) return null
const response = await fetch(ttfUrl)
if (!response.ok) return null
return response.arrayBuffer()
}
private async findLocalFont(
family: string,
style?: string,

View file

@ -3,3 +3,4 @@ export * from './style-runs'
export * from './direction'
export * from './fonts'
export * from './fallbacks'
export * from './web-fonts'

View file

@ -0,0 +1,216 @@
import {
createUnifont,
providers,
type RemoteFontSource,
type ResolveFontOptions,
type ResolveFontResult,
type Unifont
} from 'unifont'
import { IS_BROWSER } from '#core/constants'
import { parseFontStyle } from '#core/text/face'
export const WEB_FONT_PROVIDER_IDS = ['google', 'fontsource', 'bunny', 'fontshare'] as const
export type WebFontProviderId = (typeof WEB_FONT_PROVIDER_IDS)[number]
export const WEB_FONT_PROVIDER_LABELS: Record<WebFontProviderId, string> = {
google: 'Google Fonts',
fontsource: 'Fontsource',
bunny: 'Bunny Fonts',
fontshare: 'Fontshare'
}
export const DEFAULT_WEB_FONT_PROVIDER_SETTINGS: Record<WebFontProviderId, boolean> = {
google: true,
fontsource: true,
bunny: false,
fontshare: false
}
export type WebFontFetch = (url: string, init?: RequestInit) => Promise<Response>
type WebFontProvider =
| ReturnType<typeof providers.google>
| ReturnType<typeof providers.fontsource>
| ReturnType<typeof providers.bunny>
| ReturnType<typeof providers.fontshare>
type WebUnifont = Unifont<[WebFontProvider]>
type WebFontResolveOptions = Pick<ResolveFontOptions, 'weights' | 'styles' | 'formats' | 'subsets'>
const providerFactories = {
google: providers.google,
fontsource: providers.fontsource,
bunny: providers.bunny,
fontshare: providers.fontshare
} satisfies Record<WebFontProviderId, () => WebFontProvider>
async function createProviderUnifont(provider: WebFontProviderId): Promise<WebUnifont> {
return createUnifont([providerFactories[provider]()], { throwOnError: false })
}
function isRemoteFontSource(
source: RemoteFontSource | { name: string }
): source is RemoteFontSource {
return 'url' in source
}
export class WebFontResolver {
private enabled = new Set<WebFontProviderId>(
WEB_FONT_PROVIDER_IDS.filter((provider) => DEFAULT_WEB_FONT_PROVIDER_SETTINGS[provider])
)
private unifontPromises = new Map<WebFontProviderId, Promise<WebUnifont>>()
private familiesCache = new Map<WebFontProviderId, string[]>()
private familiesPromises = new Map<WebFontProviderId, Promise<string[]>>()
private failedFonts = new Set<string>()
private fontPromises = new Map<string, Promise<ArrayBuffer | null>>()
private remoteFetch: WebFontFetch | null = null
setEnabled(settings: Partial<Record<WebFontProviderId, boolean>>): void {
this.enabled = new Set(WEB_FONT_PROVIDER_IDS.filter((provider) => settings[provider] === true))
}
setRemoteFetch(fetcher: WebFontFetch | null): void {
this.remoteFetch = fetcher
this.unifontPromises.clear()
this.familiesPromises.clear()
this.familiesCache.clear()
}
enabledProviders(): WebFontProviderId[] {
return WEB_FONT_PROVIDER_IDS.filter((provider) => this.enabled.has(provider))
}
preloadFamilies(): void {
if (IS_BROWSER && !this.remoteFetch) return
for (const provider of this.enabledProviders()) void this.listFamilies(provider)
}
async listFamilies(provider: WebFontProviderId): Promise<string[]> {
const cached = this.familiesCache.get(provider)
if (cached) return cached
let promise = this.familiesPromises.get(provider)
if (!promise) {
promise = this.loadFamilies(provider)
this.familiesPromises.set(provider, promise)
}
return promise
}
async fetchFont(families: string[], style: string): Promise<ArrayBuffer | null> {
const providers = this.enabledProviders()
if (providers.length === 0 || (IS_BROWSER && !this.remoteFetch)) return null
for (const family of families) {
for (const provider of providers) {
const buffer = await this.fetchFromProvider(family, style, provider)
if (buffer) return buffer
}
}
return null
}
private async withFetchProxy<T>(operation: () => Promise<T>): Promise<T> {
if (!this.remoteFetch) return operation()
const originalFetch = globalThis.fetch
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' || input instanceof URL ? input.toString() : input.url
if (url.startsWith('https://') || url.startsWith('http://')) {
return (
this.remoteFetch?.(url, init) ?? Promise.reject(new TypeError('No font proxy fetcher'))
)
}
return originalFetch(input, init)
}) as typeof fetch
try {
return await operation()
} finally {
globalThis.fetch = originalFetch
}
}
private async fetchRemote(url: string, init?: RequestInit): Promise<Response> {
if (this.remoteFetch) return this.remoteFetch(url, init)
return fetch(url, init)
}
private async unifont(provider: WebFontProviderId): Promise<WebUnifont> {
let promise = this.unifontPromises.get(provider)
if (!promise) {
promise = this.withFetchProxy(() => createProviderUnifont(provider))
this.unifontPromises.set(provider, promise)
}
return promise
}
private async loadFamilies(provider: WebFontProviderId): Promise<string[]> {
if (typeof fetch === 'undefined' || (IS_BROWSER && !this.remoteFetch)) return []
try {
const unifont = await this.unifont(provider)
const listedFamilies = await this.withFetchProxy(() => unifont.listFonts())
const families = listedFamilies ? [...new Set(listedFamilies)].sort() : []
this.familiesCache.set(provider, families)
return families
} catch {
this.familiesCache.set(provider, [])
return []
}
}
private async fetchFromProvider(
family: string,
style: string,
provider: WebFontProviderId
): Promise<ArrayBuffer | null> {
const key = `${provider}|${family}|${style}`
if (this.failedFonts.has(key)) return null
let promise = this.fontPromises.get(key)
if (!promise) {
promise = this.loadFromProvider(family, style, provider)
this.fontPromises.set(key, promise)
}
const result = await promise
this.fontPromises.delete(key)
if (!result) this.failedFonts.add(key)
return result
}
private async loadFromProvider(
family: string,
style: string,
provider: WebFontProviderId
): Promise<ArrayBuffer | null> {
try {
const parsed = parseFontStyle(style)
const unifont = await this.unifont(provider)
const options = {
weights: [String(parsed.weight)],
styles: [parsed.italic ? 'italic' : 'normal'],
formats: ['ttf'],
subsets: ['latin']
} satisfies WebFontResolveOptions
const result = await this.withFetchProxy<ResolveFontResult>(() =>
unifont.resolveFont(family, options)
)
const sources = result.fonts
.toSorted((a, b) => (a.meta?.priority ?? 0) - (b.meta?.priority ?? 0))
.flatMap((font) => font.src.filter(isRemoteFontSource))
if (sources.length === 0) return null
const source =
sources.find((item) => item.format === 'truetype' || item.format === 'ttf') ?? sources[0]
const response = await this.fetchRemote(source.url)
if (!response.ok) return null
return await response.arrayBuffer()
} catch {
return null
}
}
}

View file

@ -347,6 +347,48 @@ export const dialogMessages = i18n('dialogs', {
closeTab: params('Close {name}'),
showUI: params('Show UI ({shortcut})'),
fontSettings: 'Font settings',
fontSettingsDesktopDescription:
'Access system fonts, online providers, fallback packs, and cached downloads.',
fontSettingsBrowserDescription:
'Allow browser access to local fonts and manage online font providers.',
localFonts: 'Local fonts',
onlineFonts: 'Online fonts',
downloadedCache: 'Downloaded cache',
lastUpdated: 'Last updated',
enabled: 'Enabled',
disabled: 'Disabled',
denied: 'Denied',
unavailable: 'Unavailable',
notRequested: 'Not requested',
never: 'Never',
systemFontAccess: 'System font access',
systemFontsAvailable: 'System fonts are available.',
allowBrowserFontAccess: 'Allow browser font access when system fonts are missing.',
allow: 'Allow',
requesting: 'Requesting…',
onlineFontProviders: 'Online font providers',
downloadMissingWebFonts: 'Download missing web fonts through enabled providers.',
webFontProvidersRequireDesktopApp:
'Online font provider catalogs are unavailable in the web app. Download the desktop app to browse and load provider fonts.',
enable: 'Enable',
disable: 'Disable',
fallbackPacks: 'Fallback packs',
downloadFallbackPacksDescription:
'Download CJK and Arabic fallbacks before opening files that need them.',
download: 'Download',
downloading: 'Downloading…',
refresh: 'Refresh',
clearCache: 'Clear cache',
localFontAccessEnabled: 'Local font access enabled.',
localFontAccessNotGranted: 'Local font access was not granted.',
onlineFontProvidersEnabled: 'Online font providers enabled.',
onlineFontProvidersDisabled: 'Online font providers disabled.',
fontProviderEnabled: params('Enabled {provider}.'),
fontProviderDisabled: params('Disabled {provider}.'),
fallbackFontsDownloaded: 'Fallback fonts downloaded.',
fallbackFontsDownloadFailed: 'Could not download fallback fonts.',
downloadedFontCacheCleared: 'Downloaded font cache cleared.',
downloadedFontCacheClearFailed: 'Could not clear downloaded font cache.',
you: 'You',
youSuffix: 'you',
followingPeerStop: params('Following {name} (click to stop)'),

View file

@ -342,6 +342,44 @@
"closeTab": "{name} schließen",
"showUI": "UI anzeigen ({shortcut})",
"fontSettings": "Schrifteinstellungen",
"fontSettingsDesktopDescription": "Auf Systemschriften, Online-Anbieter, Fallback-Pakete und zwischengespeicherte Downloads zugreifen.",
"fontSettingsBrowserDescription": "Browserzugriff auf lokale Schriften erlauben und Online-Schriftanbieter verwalten.",
"localFonts": "Lokale Schriften",
"onlineFonts": "Online-Schriften",
"downloadedCache": "Download-Cache",
"lastUpdated": "Zuletzt aktualisiert",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"denied": "Verweigert",
"unavailable": "Nicht verfügbar",
"notRequested": "Nicht angefragt",
"never": "Nie",
"systemFontAccess": "Zugriff auf Systemschriften",
"systemFontsAvailable": "Systemschriften sind verfügbar.",
"allowBrowserFontAccess": "Browser-Schriftzugriff erlauben, wenn Systemschriften fehlen.",
"allow": "Erlauben",
"requesting": "Wird angefragt…",
"onlineFontProviders": "Online-Schriftanbieter",
"downloadMissingWebFonts": "Fehlende Web-Schriften über aktivierte Anbieter herunterladen.",
"webFontProvidersRequireDesktopApp": "Online-Schriftanbieter-Kataloge sind in der Web-App nicht verfügbar. Lade die Desktop-App herunter, um Anbieter-Schriften zu durchsuchen und zu laden.",
"enable": "Aktivieren",
"disable": "Deaktivieren",
"fallbackPacks": "Fallback-Pakete",
"downloadFallbackPacksDescription": "CJK- und Arabisch-Fallbacks vor dem Öffnen von Dateien herunterladen, die sie benötigen.",
"download": "Herunterladen",
"downloading": "Wird heruntergeladen…",
"refresh": "Aktualisieren",
"clearCache": "Cache leeren",
"localFontAccessEnabled": "Lokaler Schriftzugriff aktiviert.",
"localFontAccessNotGranted": "Lokaler Schriftzugriff wurde nicht gewährt.",
"onlineFontProvidersEnabled": "Online-Schriftanbieter aktiviert.",
"onlineFontProvidersDisabled": "Online-Schriftanbieter deaktiviert.",
"fontProviderEnabled": "{provider} aktiviert.",
"fontProviderDisabled": "{provider} deaktiviert.",
"fallbackFontsDownloaded": "Fallback-Schriften heruntergeladen.",
"fallbackFontsDownloadFailed": "Fallback-Schriften konnten nicht heruntergeladen werden.",
"downloadedFontCacheCleared": "Download-Schriftcache geleert.",
"downloadedFontCacheClearFailed": "Download-Schriftcache konnte nicht geleert werden.",
"you": "Du",
"youSuffix": "du",
"followingPeerStop": "Folge {name} (zum Stoppen klicken)",

View file

@ -342,6 +342,44 @@
"closeTab": "Cerrar {name}",
"showUI": "Mostrar interfaz ({shortcut})",
"fontSettings": "Ajustes de fuentes",
"fontSettingsDesktopDescription": "Accede a fuentes del sistema, proveedores en línea, paquetes de respaldo y descargas en caché.",
"fontSettingsBrowserDescription": "Permite al navegador acceder a fuentes locales y gestiona proveedores de fuentes en línea.",
"localFonts": "Fuentes locales",
"onlineFonts": "Fuentes en línea",
"downloadedCache": "Caché descargada",
"lastUpdated": "Última actualización",
"enabled": "Activado",
"disabled": "Desactivado",
"denied": "Denegado",
"unavailable": "No disponible",
"notRequested": "No solicitado",
"never": "Nunca",
"systemFontAccess": "Acceso a fuentes del sistema",
"systemFontsAvailable": "Las fuentes del sistema están disponibles.",
"allowBrowserFontAccess": "Permite el acceso a fuentes del navegador cuando falten fuentes del sistema.",
"allow": "Permitir",
"requesting": "Solicitando…",
"onlineFontProviders": "Proveedores de fuentes en línea",
"downloadMissingWebFonts": "Descarga fuentes web faltantes mediante los proveedores activados.",
"webFontProvidersRequireDesktopApp": "Los catálogos de proveedores de fuentes en línea no están disponibles en la app web. Descarga la app de escritorio para explorar y cargar fuentes de proveedores.",
"enable": "Activar",
"disable": "Desactivar",
"fallbackPacks": "Paquetes de respaldo",
"downloadFallbackPacksDescription": "Descarga respaldos CJK y árabes antes de abrir archivos que los necesiten.",
"download": "Descargar",
"downloading": "Descargando…",
"refresh": "Actualizar",
"clearCache": "Borrar caché",
"localFontAccessEnabled": "Acceso a fuentes locales activado.",
"localFontAccessNotGranted": "No se concedió acceso a fuentes locales.",
"onlineFontProvidersEnabled": "Proveedores de fuentes en línea activados.",
"onlineFontProvidersDisabled": "Proveedores de fuentes en línea desactivados.",
"fontProviderEnabled": "{provider} activado.",
"fontProviderDisabled": "{provider} desactivado.",
"fallbackFontsDownloaded": "Fuentes de respaldo descargadas.",
"fallbackFontsDownloadFailed": "No se pudieron descargar las fuentes de respaldo.",
"downloadedFontCacheCleared": "Caché de fuentes descargadas borrada.",
"downloadedFontCacheClearFailed": "No se pudo borrar la caché de fuentes descargadas.",
"you": "Tú",
"youSuffix": "tú",
"followingPeerStop": "Siguiendo a {name} (haz clic para detener)",

View file

@ -342,6 +342,44 @@
"closeTab": "Fermer {name}",
"showUI": "Afficher linterface ({shortcut})",
"fontSettings": "Réglages des polices",
"fontSettingsDesktopDescription": "Accédez aux polices système, aux fournisseurs en ligne, aux packs de secours et aux téléchargements en cache.",
"fontSettingsBrowserDescription": "Autorisez laccès du navigateur aux polices locales et gérez les fournisseurs de polices en ligne.",
"localFonts": "Polices locales",
"onlineFonts": "Polices en ligne",
"downloadedCache": "Cache téléchargé",
"lastUpdated": "Dernière mise à jour",
"enabled": "Activé",
"disabled": "Désactivé",
"denied": "Refusé",
"unavailable": "Indisponible",
"notRequested": "Non demandé",
"never": "Jamais",
"systemFontAccess": "Accès aux polices système",
"systemFontsAvailable": "Les polices système sont disponibles.",
"allowBrowserFontAccess": "Autorisez laccès du navigateur aux polices lorsque des polices système manquent.",
"allow": "Autoriser",
"requesting": "Demande…",
"onlineFontProviders": "Fournisseurs de polices en ligne",
"downloadMissingWebFonts": "Télécharger les polices web manquantes via les fournisseurs activés.",
"webFontProvidersRequireDesktopApp": "Les catalogues de fournisseurs de polices en ligne ne sont pas disponibles dans lapp web. Téléchargez lapp de bureau pour parcourir et charger les polices des fournisseurs.",
"enable": "Activer",
"disable": "Désactiver",
"fallbackPacks": "Packs de secours",
"downloadFallbackPacksDescription": "Téléchargez les polices de secours CJK et arabes avant douvrir les fichiers qui en ont besoin.",
"download": "Télécharger",
"downloading": "Téléchargement…",
"refresh": "Actualiser",
"clearCache": "Vider le cache",
"localFontAccessEnabled": "Accès aux polices locales activé.",
"localFontAccessNotGranted": "Laccès aux polices locales na pas été accordé.",
"onlineFontProvidersEnabled": "Fournisseurs de polices en ligne activés.",
"onlineFontProvidersDisabled": "Fournisseurs de polices en ligne désactivés.",
"fontProviderEnabled": "{provider} activé.",
"fontProviderDisabled": "{provider} désactivé.",
"fallbackFontsDownloaded": "Polices de secours téléchargées.",
"fallbackFontsDownloadFailed": "Impossible de télécharger les polices de secours.",
"downloadedFontCacheCleared": "Cache des polices téléchargées vidé.",
"downloadedFontCacheClearFailed": "Impossible de vider le cache des polices téléchargées.",
"you": "Vous",
"youSuffix": "vous",
"followingPeerStop": "Suit {name} (cliquer pour arrêter)",

View file

@ -342,6 +342,44 @@
"closeTab": "Chiudi {name}",
"showUI": "Mostra interfaccia ({shortcut})",
"fontSettings": "Impostazioni font",
"fontSettingsDesktopDescription": "Accedi ai font di sistema, ai provider online, ai pacchetti di fallback e ai download nella cache.",
"fontSettingsBrowserDescription": "Consenti al browser di accedere ai font locali e gestisci i provider di font online.",
"localFonts": "Font locali",
"onlineFonts": "Font online",
"downloadedCache": "Cache scaricata",
"lastUpdated": "Ultimo aggiornamento",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"denied": "Negato",
"unavailable": "Non disponibile",
"notRequested": "Non richiesto",
"never": "Mai",
"systemFontAccess": "Accesso ai font di sistema",
"systemFontsAvailable": "I font di sistema sono disponibili.",
"allowBrowserFontAccess": "Consenti laccesso ai font del browser quando mancano font di sistema.",
"allow": "Consenti",
"requesting": "Richiesta…",
"onlineFontProviders": "Provider di font online",
"downloadMissingWebFonts": "Scarica i font web mancanti tramite i provider abilitati.",
"webFontProvidersRequireDesktopApp": "I cataloghi dei provider di font online non sono disponibili nellapp web. Scarica lapp desktop per sfogliare e caricare i font dei provider.",
"enable": "Abilita",
"disable": "Disabilita",
"fallbackPacks": "Pacchetti di fallback",
"downloadFallbackPacksDescription": "Scarica fallback CJK e arabi prima di aprire file che li richiedono.",
"download": "Scarica",
"downloading": "Download…",
"refresh": "Aggiorna",
"clearCache": "Svuota cache",
"localFontAccessEnabled": "Accesso ai font locali abilitato.",
"localFontAccessNotGranted": "Accesso ai font locali non concesso.",
"onlineFontProvidersEnabled": "Provider di font online abilitati.",
"onlineFontProvidersDisabled": "Provider di font online disabilitati.",
"fontProviderEnabled": "{provider} abilitato.",
"fontProviderDisabled": "{provider} disabilitato.",
"fallbackFontsDownloaded": "Font di fallback scaricati.",
"fallbackFontsDownloadFailed": "Impossibile scaricare i font di fallback.",
"downloadedFontCacheCleared": "Cache dei font scaricati svuotata.",
"downloadedFontCacheClearFailed": "Impossibile svuotare la cache dei font scaricati.",
"you": "Tu",
"youSuffix": "tu",
"followingPeerStop": "Segui {name} (clic per interrompere)",

View file

@ -342,6 +342,44 @@
"closeTab": "{name} を閉じる",
"showUI": "UIを表示 ({shortcut})",
"fontSettings": "フォント設定",
"fontSettingsDesktopDescription": "システムフォント、オンラインプロバイダー、フォールバックパック、キャッシュ済みダウンロードにアクセスします。",
"fontSettingsBrowserDescription": "ローカルフォントへのブラウザーアクセスを許可し、オンラインフォントプロバイダーを管理します。",
"localFonts": "ローカルフォント",
"onlineFonts": "オンラインフォント",
"downloadedCache": "ダウンロード済みキャッシュ",
"lastUpdated": "最終更新",
"enabled": "有効",
"disabled": "無効",
"denied": "拒否",
"unavailable": "利用不可",
"notRequested": "未要求",
"never": "なし",
"systemFontAccess": "システムフォントアクセス",
"systemFontsAvailable": "システムフォントを利用できます。",
"allowBrowserFontAccess": "システムフォントが見つからない場合にブラウザーのフォントアクセスを許可します。",
"allow": "許可",
"requesting": "要求中…",
"onlineFontProviders": "オンラインフォントプロバイダー",
"downloadMissingWebFonts": "有効なプロバイダーから不足しているWebフォントをダウンロードします。",
"webFontProvidersRequireDesktopApp": "オンラインフォントプロバイダーのカタログはWebアプリでは利用できません。プロバイダーのフォントを参照して読み込むにはデスクトップアプリをダウンロードしてください。",
"enable": "有効にする",
"disable": "無効にする",
"fallbackPacks": "フォールバックパック",
"downloadFallbackPacksDescription": "必要なファイルを開く前に、CJKとアラビア語のフォールバックをダウンロードします。",
"download": "ダウンロード",
"downloading": "ダウンロード中…",
"refresh": "更新",
"clearCache": "キャッシュをクリア",
"localFontAccessEnabled": "ローカルフォントアクセスを有効にしました。",
"localFontAccessNotGranted": "ローカルフォントアクセスは許可されませんでした。",
"onlineFontProvidersEnabled": "オンラインフォントプロバイダーを有効にしました。",
"onlineFontProvidersDisabled": "オンラインフォントプロバイダーを無効にしました。",
"fontProviderEnabled": "{provider} を有効にしました。",
"fontProviderDisabled": "{provider} を無効にしました。",
"fallbackFontsDownloaded": "フォールバックフォントをダウンロードしました。",
"fallbackFontsDownloadFailed": "フォールバックフォントをダウンロードできませんでした。",
"downloadedFontCacheCleared": "ダウンロード済みフォントキャッシュをクリアしました。",
"downloadedFontCacheClearFailed": "ダウンロード済みフォントキャッシュをクリアできませんでした。",
"you": "あなた",
"youSuffix": "あなた",
"followingPeerStop": "{name} をフォロー中 (クリックして停止)",

View file

@ -342,6 +342,44 @@
"closeTab": "Zamknij {name}",
"showUI": "Pokaż interfejs ({shortcut})",
"fontSettings": "Ustawienia czcionek",
"fontSettingsDesktopDescription": "Uzyskaj dostęp do czcionek systemowych, dostawców online, pakietów zapasowych i pobranych plików w pamięci podręcznej.",
"fontSettingsBrowserDescription": "Zezwól przeglądarce na dostęp do lokalnych czcionek i zarządzaj dostawcami czcionek online.",
"localFonts": "Czcionki lokalne",
"onlineFonts": "Czcionki online",
"downloadedCache": "Pobrana pamięć podręczna",
"lastUpdated": "Ostatnia aktualizacja",
"enabled": "Włączone",
"disabled": "Wyłączone",
"denied": "Odmówiono",
"unavailable": "Niedostępne",
"notRequested": "Nie zażądano",
"never": "Nigdy",
"systemFontAccess": "Dostęp do czcionek systemowych",
"systemFontsAvailable": "Czcionki systemowe są dostępne.",
"allowBrowserFontAccess": "Zezwól przeglądarce na dostęp do czcionek, gdy brakuje czcionek systemowych.",
"allow": "Zezwól",
"requesting": "Żądanie…",
"onlineFontProviders": "Dostawcy czcionek online",
"downloadMissingWebFonts": "Pobieraj brakujące czcionki webowe przez włączonych dostawców.",
"webFontProvidersRequireDesktopApp": "Katalogi dostawców czcionek online nie są dostępne w aplikacji webowej. Pobierz aplikację desktopową, aby przeglądać i ładować czcionki dostawców.",
"enable": "Włącz",
"disable": "Wyłącz",
"fallbackPacks": "Pakiety zapasowe",
"downloadFallbackPacksDescription": "Pobierz zapasowe czcionki CJK i arabskie przed otwarciem plików, które ich wymagają.",
"download": "Pobierz",
"downloading": "Pobieranie…",
"refresh": "Odśwież",
"clearCache": "Wyczyść pamięć podręczną",
"localFontAccessEnabled": "Dostęp do lokalnych czcionek włączony.",
"localFontAccessNotGranted": "Nie przyznano dostępu do lokalnych czcionek.",
"onlineFontProvidersEnabled": "Dostawcy czcionek online włączeni.",
"onlineFontProvidersDisabled": "Dostawcy czcionek online wyłączeni.",
"fontProviderEnabled": "Włączono {provider}.",
"fontProviderDisabled": "Wyłączono {provider}.",
"fallbackFontsDownloaded": "Pobrano czcionki zapasowe.",
"fallbackFontsDownloadFailed": "Nie udało się pobrać czcionek zapasowych.",
"downloadedFontCacheCleared": "Wyczyszczono pamięć podręczną pobranych czcionek.",
"downloadedFontCacheClearFailed": "Nie udało się wyczyścić pamięci podręcznej pobranych czcionek.",
"you": "Ty",
"youSuffix": "ty",
"followingPeerStop": "Obserwujesz {name} (kliknij, aby zatrzymać)",

View file

@ -342,6 +342,44 @@
"closeTab": "Закрыть {name}",
"showUI": "Показать интерфейс ({shortcut})",
"fontSettings": "Настройки шрифтов",
"fontSettingsDesktopDescription": "Доступ к системным шрифтам, онлайн-провайдерам, резервным наборам и кэшу загрузок.",
"fontSettingsBrowserDescription": "Разрешите браузеру доступ к локальным шрифтам и управляйте онлайн-провайдерами шрифтов.",
"localFonts": "Локальные шрифты",
"onlineFonts": "Онлайн-шрифты",
"downloadedCache": "Кэш загрузок",
"lastUpdated": "Последнее обновление",
"enabled": "Включено",
"disabled": "Отключено",
"denied": "Запрещено",
"unavailable": "Недоступно",
"notRequested": "Не запрошено",
"never": "Никогда",
"systemFontAccess": "Доступ к системным шрифтам",
"systemFontsAvailable": "Системные шрифты доступны.",
"allowBrowserFontAccess": "Разрешите доступ браузера к шрифтам, если системные шрифты отсутствуют.",
"allow": "Разрешить",
"requesting": "Запрос…",
"onlineFontProviders": "Онлайн-провайдеры шрифтов",
"downloadMissingWebFonts": "Загружайте отсутствующие веб-шрифты через включённых провайдеров.",
"webFontProvidersRequireDesktopApp": "Каталоги провайдеров онлайн-шрифтов недоступны в веб-приложении. Скачайте настольное приложение, чтобы просматривать и загружать шрифты провайдеров.",
"enable": "Включить",
"disable": "Отключить",
"fallbackPacks": "Резервные наборы",
"downloadFallbackPacksDescription": "Загрузите резервные CJK- и арабские шрифты перед открытием файлов, которым они нужны.",
"download": "Загрузить",
"downloading": "Загрузка…",
"refresh": "Обновить",
"clearCache": "Очистить кэш",
"localFontAccessEnabled": "Доступ к локальным шрифтам включён.",
"localFontAccessNotGranted": "Доступ к локальным шрифтам не предоставлен.",
"onlineFontProvidersEnabled": "Онлайн-провайдеры шрифтов включены.",
"onlineFontProvidersDisabled": "Онлайн-провайдеры шрифтов отключены.",
"fontProviderEnabled": "{provider} включён.",
"fontProviderDisabled": "{provider} отключён.",
"fallbackFontsDownloaded": "Резервные шрифты загружены.",
"fallbackFontsDownloadFailed": "Не удалось загрузить резервные шрифты.",
"downloadedFontCacheCleared": "Кэш загруженных шрифтов очищен.",
"downloadedFontCacheClearFailed": "Не удалось очистить кэш загруженных шрифтов.",
"you": "Вы",
"youSuffix": "вы",
"followingPeerStop": "Следите за {name} (нажмите, чтобы остановить)",

View file

@ -342,6 +342,44 @@
"closeTab": "关闭 {name}",
"showUI": "显示界面({shortcut}",
"fontSettings": "字体设置",
"fontSettingsDesktopDescription": "访问系统字体、在线提供商、后备字体包和缓存下载。",
"fontSettingsBrowserDescription": "允许浏览器访问本地字体,并管理在线字体提供商。",
"localFonts": "本地字体",
"onlineFonts": "在线字体",
"downloadedCache": "下载缓存",
"lastUpdated": "上次更新",
"enabled": "已启用",
"disabled": "已停用",
"denied": "已拒绝",
"unavailable": "不可用",
"notRequested": "未请求",
"never": "从不",
"systemFontAccess": "系统字体访问",
"systemFontsAvailable": "系统字体可用。",
"allowBrowserFontAccess": "缺少系统字体时,允许浏览器访问字体。",
"allow": "允许",
"requesting": "正在请求…",
"onlineFontProviders": "在线字体提供商",
"downloadMissingWebFonts": "通过已启用的提供商下载缺失的网页字体。",
"webFontProvidersRequireDesktopApp": "网页版暂不支持在线字体提供商目录。请下载桌面应用来浏览和加载提供商字体。",
"enable": "启用",
"disable": "停用",
"fallbackPacks": "后备字体包",
"downloadFallbackPacksDescription": "在打开需要这些字体的文件前,下载 CJK 和阿拉伯语后备字体。",
"download": "下载",
"downloading": "正在下载…",
"refresh": "刷新",
"clearCache": "清除缓存",
"localFontAccessEnabled": "本地字体访问已启用。",
"localFontAccessNotGranted": "未授予本地字体访问权限。",
"onlineFontProvidersEnabled": "在线字体提供商已启用。",
"onlineFontProvidersDisabled": "在线字体提供商已停用。",
"fontProviderEnabled": "已启用 {provider}。",
"fontProviderDisabled": "已停用 {provider}。",
"fallbackFontsDownloaded": "后备字体已下载。",
"fallbackFontsDownloadFailed": "无法下载后备字体。",
"downloadedFontCacheCleared": "已清除下载的字体缓存。",
"downloadedFontCacheClearFailed": "无法清除下载的字体缓存。",
"you": "你",
"youSuffix": "你",
"followingPeerStop": "正在跟随 {name}(点击停止)",

View file

@ -7,6 +7,9 @@ import type { LanguageModel } from 'ai'
import type { AIProviderID } from '@open-pencil/core/constants'
import { isTauri } from '@/app/tauri/env'
import { tauriFetch } from '@/app/tauri/http'
export type ModelConfig = {
providerID: AIProviderID
apiKey: string
@ -27,13 +30,19 @@ export function resolveLanguageModelID(
return config.modelID
}
function desktopFetch(): typeof fetch | undefined {
return isTauri() ? tauriFetch : undefined
}
export function createLanguageModel(config: ModelConfig): LanguageModel {
const effectiveModelID = resolveLanguageModelID(config)
const fetch = desktopFetch()
switch (config.providerID) {
case 'openrouter': {
const openrouter = createOpenRouter({
apiKey: config.apiKey,
fetch,
headers: {
'X-OpenRouter-Title': 'OpenPencil',
'HTTP-Referer': 'https://github.com/open-pencil/open-pencil'
@ -42,39 +51,42 @@ export function createLanguageModel(config: ModelConfig): LanguageModel {
return openrouter(effectiveModelID)
}
case 'anthropic': {
const anthropic = createAnthropic({ apiKey: config.apiKey })
const anthropic = createAnthropic({ apiKey: config.apiKey, fetch })
return anthropic(effectiveModelID)
}
case 'openai': {
const openai = createOpenAI({ apiKey: config.apiKey })
const openai = createOpenAI({ apiKey: config.apiKey, fetch })
return openai(effectiveModelID)
}
case 'google': {
const google = createGoogleGenerativeAI({ apiKey: config.apiKey })
const google = createGoogleGenerativeAI({ apiKey: config.apiKey, fetch })
return google(effectiveModelID)
}
case 'deepseek': {
const deepseek = createDeepSeek({ apiKey: config.apiKey })
const deepseek = createDeepSeek({ apiKey: config.apiKey, fetch })
return deepseek(effectiveModelID)
}
case 'zai': {
const zai = createAnthropic({
apiKey: config.apiKey,
baseURL: 'https://api.z.ai/api/anthropic'
baseURL: 'https://api.z.ai/api/anthropic',
fetch
})
return zai(effectiveModelID)
}
case 'minimax': {
const minimax = createOpenAI({
apiKey: config.apiKey,
baseURL: 'https://api.minimax.io/v1'
baseURL: 'https://api.minimax.io/v1',
fetch
})
return minimax.chat(effectiveModelID)
}
case 'openai-compatible': {
const custom = createOpenAI({
apiKey: config.apiKey,
baseURL: config.customBaseURL
baseURL: config.customBaseURL,
fetch
})
return config.customAPIType === 'responses'
? custom.responses(effectiveModelID)
@ -83,7 +95,8 @@ export function createLanguageModel(config: ModelConfig): LanguageModel {
case 'anthropic-compatible': {
const custom = createAnthropic({
apiKey: config.apiKey,
baseURL: config.customBaseURL
baseURL: config.customBaseURL,
fetch
})
return custom(effectiveModelID)
}

View file

@ -1,7 +1,7 @@
import { FigmaAPI } from '@open-pencil/core/figma-api'
import type { EditorStore } from '@/app/editor/active-store'
import { listFonts } from '@/app/editor/fonts'
import { listFamilies, listFonts } from '@/app/editor/fonts'
export function makeFigmaFromStore(store: EditorStore): FigmaAPI {
const api = new FigmaAPI(store.graph)
@ -20,10 +20,15 @@ export function makeFigmaFromStore(store: EditorStore): FigmaAPI {
api.exportImage = (nodeIds, opts) =>
store.renderExportImage(nodeIds, opts.scale ?? 1, opts.format ?? 'PNG')
api.listAvailableFontsAsync = async () => {
const fonts = await listFonts()
return fonts.flatMap(({ family, styles }) =>
const [systemFonts, familyOptions] = await Promise.all([listFonts(), listFamilies()])
const fonts = systemFonts.flatMap(({ family, styles }) =>
styles.map((style) => ({ fontName: { family, style } }))
)
const seenFamilies = new Set(systemFonts.map(({ family }) => family))
for (const { family } of familyOptions) {
if (!seenFamilies.has(family)) fonts.push({ fontName: { family, style: 'Regular' } })
}
return fonts
}
return api
}

View file

@ -1,32 +1,71 @@
import { useLocalStorage } from '@vueuse/core'
import { watch } from 'vue'
import {
DEFAULT_WEB_FONT_PROVIDER_SETTINGS,
WEB_FONT_PROVIDER_IDS,
fontManager,
styleToWeight,
type FontFamilyOption,
type LocalFontAccessState
type LocalFontAccessState,
type WebFontProviderId
} from '@open-pencil/core/text'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { dialogMessages } from '@open-pencil/vue'
import {
clearDownloadedFontCache as clearTauriDownloadedFontCache,
createTauriDownloadedFontCache,
downloadedFontCacheSummary as tauriDownloadedFontCacheSummary
} from '@/app/editor/fonts/cache'
import { toast } from '@/app/shell/ui'
import { isTauri } from '@/app/tauri/env'
import { tauriFetch } from '@/app/tauri/http'
if (typeof navigator !== 'undefined') {
fontManager.setFallbackUserAgent(navigator.userAgent)
}
export const googleFontsEnabled = useLocalStorage('op-google-fonts-enabled', true)
export type FontProviderSettings = Record<WebFontProviderId, boolean>
export const onlineFontsEnabled = useLocalStorage('op-online-fonts-enabled', true)
export const fontProviderSettings = useLocalStorage<FontProviderSettings>(
'op-font-providers',
DEFAULT_WEB_FONT_PROVIDER_SETTINGS
)
watch(
[onlineFontsEnabled, fontProviderSettings],
() => {
fontManager.setOnlineFontProviders(
onlineFontsEnabled.value
? Object.fromEntries(
WEB_FONT_PROVIDER_IDS.map((provider) => [
provider,
fontProviderSettings.value[provider]
])
)
: {}
)
},
{ deep: true, immediate: true }
)
let tauriFontCacheConfigured = false
let webFontUnavailableToastShown = false
function showWebFontUnavailableToast(): void {
if (webFontUnavailableToastShown || isTauri() || !onlineFontsEnabled.value) return
if (!WEB_FONT_PROVIDER_IDS.some((provider) => fontProviderSettings.value[provider])) return
webFontUnavailableToastShown = true
toast.warning(dialogMessages.get().webFontProvidersRequireDesktopApp)
}
function configureTauriFontCache() {
if (tauriFontCacheConfigured || !isTauri()) return
tauriFontCacheConfigured = true
fontManager.setDownloadedFontCache(createTauriDownloadedFontCache())
fontManager.setWebFontFetch(tauriFetch)
}
configureTauriFontCache()
@ -59,7 +98,7 @@ export function preloadFonts(): void {
void getTauriFonts().then(registerFontFaces)
return
}
if (googleFontsEnabled.value) fontManager.preloadGoogleFamilies()
if (onlineFontsEnabled.value) fontManager.preloadWebFontFamilies()
}
export function localFontAccessState(): LocalFontAccessState {
@ -99,11 +138,17 @@ function registerFontFaces(fonts: TauriFontFamily[]): void {
export async function listFamilies(): Promise<FontFamilyOption[]> {
configureTauriFontCache()
if (isTauri()) {
const fonts = await getTauriFonts()
return fonts.map((f) => ({ family: f.family, source: 'local' }))
const [systemFonts, webFonts] = await Promise.all([
getTauriFonts(),
fontManager.listFamilyOptions()
])
const byFamily = new Map(webFonts.map((font) => [font.family, font]))
for (const font of systemFonts)
byFamily.set(font.family, { family: font.family, source: 'local' })
return [...byFamily.values()].sort((a, b) => a.family.localeCompare(b.family))
}
const fonts = await fontManager.listFamilyOptions()
return googleFontsEnabled.value ? fonts : fonts.filter((font) => font.source !== 'google')
showWebFontUnavailableToast()
return fontManager.listFamilyOptions()
}
export async function listFonts(): Promise<TauriFontFamily[]> {
@ -156,5 +201,7 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
}
}
return fontManager.loadFont(family, style)
const loaded = await fontManager.loadFont(family, style)
if (!loaded) showWebFontUnavailableToast()
return loaded
}

51
src/app/tauri/http.ts Normal file
View file

@ -0,0 +1,51 @@
interface ProxyHttpHeader {
name: string
value: string
}
interface ProxyHttpRequest {
url: string
method?: string
headers?: ProxyHttpHeader[]
body?: number[]
}
interface ProxyHttpResponse {
status: number
headers: ProxyHttpHeader[]
body: number[]
}
function headersToProxyHeaders(headers: Headers): ProxyHttpHeader[] {
return [...headers.entries()].map(([name, value]) => ({ name, value }))
}
async function bodyToBytes(body: BodyInit | null | undefined): Promise<number[] | undefined> {
if (body == null) return undefined
if (typeof body === 'string') return [...new TextEncoder().encode(body)]
if (body instanceof ArrayBuffer) return [...new Uint8Array(body)]
if (ArrayBuffer.isView(body))
return [...new Uint8Array(body.buffer, body.byteOffset, body.byteLength)]
if (body instanceof Blob) return [...new Uint8Array(await body.arrayBuffer())]
if (body instanceof URLSearchParams) return [...new TextEncoder().encode(body.toString())]
if (body instanceof FormData) {
return [...new Uint8Array(await new Response(body).arrayBuffer())]
}
throw new TypeError('Streaming request bodies are not supported by the desktop HTTP bridge yet')
}
export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const request = new Request(input, init)
const { invoke } = await import('@tauri-apps/api/core')
const payload: ProxyHttpRequest = {
url: request.url,
method: request.method,
headers: headersToProxyHeaders(request.headers),
body: await bodyToBytes(init?.body)
}
const response = await invoke<ProxyHttpResponse>('proxy_http_request', { request: payload })
return new Response(new Uint8Array(response.body), {
status: response.status,
headers: response.headers.map(({ name, value }): [string, string] => [name, value])
})
}

View file

@ -11,6 +11,8 @@ import {
requestLocalFontAccess
} from '@/app/editor/fonts'
import { WEB_FONT_PROVIDER_IDS } from '@open-pencil/core/text'
import type { FontPickerUi } from '@open-pencil/vue'
const modelValue = defineModel<string>({ required: true })
@ -42,7 +44,7 @@ const localFontAccess = {
}
function loadPreviewFont(family: string, source: string) {
if (source !== 'google') return
if (!WEB_FONT_PROVIDER_IDS.includes(source as (typeof WEB_FONT_PROVIDER_IDS)[number])) return
if (previewFontLoads.has(family)) return
previewFontLoads.add(family)
void loadFont(family)

View file

@ -4,6 +4,8 @@ import { onMounted, ref } from 'vue'
import { isTauri } from '@/app/tauri/env'
import { useFontSettings } from '@/components/FontSettings/use'
import { WEB_FONT_PROVIDER_IDS, WEB_FONT_PROVIDER_LABELS } from '@open-pencil/core/text'
import type { WebFontProviderId } from '@open-pencil/core/text'
import { useI18n } from '@open-pencil/vue'
import Tip from '@/components/ui/Tip.vue'
import { useButtonUI } from '@/components/ui/button'
@ -29,6 +31,7 @@ const primaryButton = useButtonUI({
ui: { base: 'w-full px-2 py-1.5 text-[10px] font-medium disabled:opacity-50' }
})
const showDownloadedFonts = isTauri()
const webFontProviderIds = WEB_FONT_PROVIDER_IDS
const popoverOpen = ref(false)
const {
@ -40,12 +43,14 @@ const {
cacheUpdatedLabel,
canRequestLocalFonts,
status,
googleFontsEnabled,
onlineFontsEnabled,
fontProviderSettings,
clearCache,
downloadFallbacks,
refreshSummary,
requestAccess,
setGoogleFontsEnabled
setFontProviderEnabled,
setOnlineFontsEnabled
} = useFontSettings()
function setPopoverOpen(value: boolean) {
@ -53,6 +58,16 @@ function setPopoverOpen(value: boolean) {
if (value) void refreshSummary()
}
function isProviderEnabled(provider: WebFontProviderId) {
return fontProviderSettings.value[provider]
}
function onProviderToggle(provider: WebFontProviderId, event: Event) {
const input = event.target
if (!(input instanceof HTMLInputElement)) return
setFontProviderEnabled(provider, input.checked)
}
onMounted(() => {
void refreshSummary()
})
@ -88,8 +103,8 @@ onMounted(() => {
<p class="mt-0.5 text-[10px] leading-relaxed text-muted">
{{
showDownloadedFonts
? 'Access system fonts, Google Fonts, fallback packs, and cached downloads.'
: 'Allow browser access to local fonts and manage Google Fonts.'
? dialogs.fontSettingsDesktopDescription
: dialogs.fontSettingsBrowserDescription
}}
</p>
</div>
@ -97,19 +112,21 @@ onMounted(() => {
<div class="grid gap-1.5 rounded border border-border bg-input/40 p-2 text-[10px]">
<div class="flex justify-between gap-3 text-muted">
<span>Local fonts</span>
<span>{{ dialogs.localFonts }}</span>
<span class="text-surface">{{ accessStateLabel }}</span>
</div>
<div class="flex justify-between gap-3 text-muted">
<span>Google Fonts</span>
<span class="text-surface">{{ googleFontsEnabled ? 'Enabled' : 'Disabled' }}</span>
<span>{{ dialogs.onlineFonts }}</span>
<span class="text-surface">{{
onlineFontsEnabled ? dialogs.enabled : dialogs.disabled
}}</span>
</div>
<div v-if="showDownloadedFonts" class="flex justify-between gap-3 text-muted">
<span>Downloaded cache</span>
<span>{{ dialogs.downloadedCache }}</span>
<span class="text-surface">{{ cacheCount }} fonts · {{ cacheSize }}</span>
</div>
<div v-if="showDownloadedFonts" class="flex justify-between gap-3 text-muted">
<span>Last updated</span>
<span>{{ dialogs.lastUpdated }}</span>
<span class="text-surface">{{ cacheUpdatedLabel }}</span>
</div>
</div>
@ -117,12 +134,12 @@ onMounted(() => {
<div class="space-y-1.5">
<div class="grid grid-cols-[1fr_auto] gap-2 rounded border border-border p-2">
<div>
<p class="text-[10px] font-medium text-surface">System font access</p>
<p class="text-[10px] font-medium text-surface">{{ dialogs.systemFontAccess }}</p>
<p class="mt-0.5 text-[10px] leading-relaxed text-muted">
{{
accessState === 'granted'
? 'System fonts are available.'
: 'Allow browser font access when system fonts are missing.'
? dialogs.systemFontsAvailable
: dialogs.allowBrowserFontAccess
}}
</p>
</div>
@ -133,26 +150,48 @@ onMounted(() => {
:disabled="busyAction !== null || !canRequestLocalFonts"
@click="requestAccess"
>
{{ busyAction === 'access' ? 'Requesting…' : 'Allow' }}
{{ busyAction === 'access' ? dialogs.requesting : dialogs.allow }}
</button>
</div>
<div class="grid grid-cols-[1fr_auto] gap-2 rounded border border-border p-2">
<div>
<p class="text-[10px] font-medium text-surface">Google Fonts</p>
<p class="mt-0.5 text-[10px] leading-relaxed text-muted">
Show fonts from Google in the font picker.
</p>
<div class="grid gap-2 rounded border border-border p-2">
<div class="grid grid-cols-[1fr_auto] gap-2">
<div>
<p class="text-[10px] font-medium text-surface">
{{ dialogs.onlineFontProviders }}
</p>
<p class="mt-0.5 text-[10px] leading-relaxed text-muted">
{{ dialogs.downloadMissingWebFonts }}
</p>
</div>
<button
type="button"
data-test-id="font-settings-toggle-online-fonts"
:class="secondaryButton.base"
:disabled="busyAction !== null"
@click="setOnlineFontsEnabled(!onlineFontsEnabled)"
>
{{ onlineFontsEnabled ? dialogs.disable : dialogs.enable }}
</button>
</div>
<div class="grid gap-1 border-t border-border pt-2">
<label
v-for="provider in webFontProviderIds"
:key="provider"
class="flex items-center justify-between gap-2 text-[10px] text-muted"
>
<span>{{ WEB_FONT_PROVIDER_LABELS[provider] }}</span>
<input
type="checkbox"
class="size-3 accent-accent disabled:opacity-50"
:checked="isProviderEnabled(provider)"
:disabled="busyAction !== null || !onlineFontsEnabled"
:data-test-id="`font-settings-provider-${provider}`"
@change="onProviderToggle(provider, $event)"
/>
</label>
</div>
<button
type="button"
data-test-id="font-settings-toggle-google-fonts"
:class="secondaryButton.base"
:disabled="busyAction !== null"
@click="setGoogleFontsEnabled(!googleFontsEnabled)"
>
{{ googleFontsEnabled ? 'Disable' : 'Enable' }}
</button>
</div>
<div
@ -160,9 +199,9 @@ onMounted(() => {
class="grid grid-cols-[1fr_auto] gap-2 rounded border border-border p-2"
>
<div>
<p class="text-[10px] font-medium text-surface">Fallback packs</p>
<p class="text-[10px] font-medium text-surface">{{ dialogs.fallbackPacks }}</p>
<p class="mt-0.5 text-[10px] leading-relaxed text-muted">
Download CJK and Arabic fallbacks before opening files that need them.
{{ dialogs.downloadFallbackPacksDescription }}
</p>
</div>
<button
@ -172,7 +211,7 @@ onMounted(() => {
:disabled="busyAction !== null"
@click="downloadFallbacks"
>
{{ busyAction === 'download' ? 'Downloading…' : 'Download' }}
{{ busyAction === 'download' ? dialogs.downloading : dialogs.download }}
</button>
</div>
</div>
@ -185,7 +224,7 @@ onMounted(() => {
:disabled="busyAction !== null"
@click="refreshSummary"
>
Refresh
{{ dialogs.refresh }}
</button>
<button
type="button"
@ -194,7 +233,7 @@ onMounted(() => {
:disabled="busyAction !== null || cacheCount === 0"
@click="clearCache"
>
Clear cache
{{ dialogs.clearCache }}
</button>
</div>

View file

@ -1,14 +1,21 @@
import { computed, ref } from 'vue'
import type { FontFamilyOption, LocalFontAccessState } from '@open-pencil/core/text'
import type {
FontFamilyOption,
LocalFontAccessState,
WebFontProviderId
} from '@open-pencil/core/text'
import { useI18n } from '@open-pencil/vue'
import {
clearDownloadedFontCache,
downloadedFontCacheSummary,
googleFontsEnabled,
fontProviderSettings,
localFontAccessState,
onlineFontsEnabled,
predownloadFallbackFonts,
requestLocalFontAccess
requestLocalFontAccess,
type FontProviderSettings
} from '@/app/editor/fonts'
import type { DownloadedFontCacheSummary } from '@/app/editor/fonts/cache'
@ -20,7 +27,8 @@ export interface FontSettingsActions {
localFontAccessState: () => LocalFontAccessState
predownloadFallbackFonts: () => Promise<unknown>
requestLocalFontAccess: () => Promise<string[] | FontFamilyOption[]>
googleFontsEnabled: { value: boolean }
onlineFontsEnabled: { value: boolean }
fontProviderSettings: { value: FontProviderSettings }
}
export type FontSettingsBusyAction = 'access' | 'download' | 'clear' | 'refresh'
@ -31,23 +39,26 @@ const defaultActions: FontSettingsActions = {
localFontAccessState,
predownloadFallbackFonts,
requestLocalFontAccess,
googleFontsEnabled
onlineFontsEnabled,
fontProviderSettings
}
export function useFontSettings(actions: FontSettingsActions = defaultActions) {
const { dialogs } = useI18n()
const cacheCount = ref(0)
const cacheByteLength = ref(0)
const cacheUpdatedAt = ref<number | null>(null)
const accessState = ref(actions.localFontAccessState())
const busyAction = ref<FontSettingsBusyAction | null>(null)
const status = ref('')
const googleFontsEnabled = actions.googleFontsEnabled
const onlineFontsEnabled = actions.onlineFontsEnabled
const fontProviderSettings = actions.fontProviderSettings
const accessStateLabel = computed(() => {
if (accessState.value === 'granted') return 'Enabled'
if (accessState.value === 'denied') return 'Denied'
if (accessState.value === 'unsupported') return 'Unavailable'
return 'Not requested'
if (accessState.value === 'granted') return dialogs.value.enabled
if (accessState.value === 'denied') return dialogs.value.denied
if (accessState.value === 'unsupported') return dialogs.value.unavailable
return dialogs.value.notRequested
})
const cacheSize = computed(() => {
@ -56,7 +67,7 @@ export function useFontSettings(actions: FontSettingsActions = defaultActions) {
})
const cacheUpdatedLabel = computed(() => {
if (cacheUpdatedAt.value === null) return 'Never'
if (cacheUpdatedAt.value === null) return dialogs.value.never
return new Date(cacheUpdatedAt.value).toLocaleDateString()
})
@ -83,18 +94,27 @@ export function useFontSettings(actions: FontSettingsActions = defaultActions) {
try {
await actions.requestLocalFontAccess()
accessState.value = actions.localFontAccessState()
status.value = 'Local font access enabled.'
status.value = dialogs.value.localFontAccessEnabled
} catch {
accessState.value = actions.localFontAccessState()
status.value = 'Local font access was not granted.'
status.value = dialogs.value.localFontAccessNotGranted
} finally {
busyAction.value = null
}
}
function setGoogleFontsEnabled(enabled: boolean) {
googleFontsEnabled.value = enabled
status.value = enabled ? 'Google Fonts enabled.' : 'Google Fonts disabled.'
function setOnlineFontsEnabled(enabled: boolean) {
onlineFontsEnabled.value = enabled
status.value = enabled
? dialogs.value.onlineFontProvidersEnabled
: dialogs.value.onlineFontProvidersDisabled
}
function setFontProviderEnabled(provider: WebFontProviderId, enabled: boolean) {
fontProviderSettings.value = { ...fontProviderSettings.value, [provider]: enabled }
status.value = enabled
? dialogs.value.fontProviderEnabled({ provider })
: dialogs.value.fontProviderDisabled({ provider })
}
async function downloadFallbacks() {
@ -103,9 +123,9 @@ export function useFontSettings(actions: FontSettingsActions = defaultActions) {
try {
await actions.predownloadFallbackFonts()
await refreshSummary()
status.value = 'Fallback fonts downloaded.'
status.value = dialogs.value.fallbackFontsDownloaded
} catch {
status.value = 'Could not download fallback fonts.'
status.value = dialogs.value.fallbackFontsDownloadFailed
} finally {
busyAction.value = null
}
@ -117,9 +137,9 @@ export function useFontSettings(actions: FontSettingsActions = defaultActions) {
try {
await actions.clearDownloadedFontCache()
await refreshSummary()
status.value = 'Downloaded font cache cleared.'
status.value = dialogs.value.downloadedFontCacheCleared
} catch {
status.value = 'Could not clear downloaded font cache.'
status.value = dialogs.value.downloadedFontCacheClearFailed
} finally {
busyAction.value = null
}
@ -134,11 +154,13 @@ export function useFontSettings(actions: FontSettingsActions = defaultActions) {
cacheSize,
cacheUpdatedLabel,
status,
googleFontsEnabled,
onlineFontsEnabled,
fontProviderSettings,
clearCache,
downloadFallbacks,
refreshSummary,
requestAccess,
setGoogleFontsEnabled
setOnlineFontsEnabled,
setFontProviderEnabled
}
}

View file

@ -26,11 +26,16 @@ test('font settings popover exposes web font access without desktop-only cache a
await expect(page.getByText('Allow browser access to local fonts')).toBeVisible()
await expect(page.getByTestId('font-settings-request-access')).toBeVisible()
await expect(page.getByTestId('font-settings-toggle-google-fonts')).toHaveText('Disable')
await page.getByTestId('font-settings-toggle-google-fonts').click()
await expect(page.getByTestId('font-settings-toggle-google-fonts')).toHaveText('Enable')
await page.getByTestId('font-settings-toggle-google-fonts').click()
await expect(page.getByTestId('font-settings-toggle-google-fonts')).toHaveText('Disable')
await expect(page.getByTestId('font-settings-toggle-online-fonts')).toHaveText('Disable')
await expect(page.getByTestId('font-settings-provider-google')).toBeChecked()
await expect(page.getByTestId('font-settings-provider-fontsource')).toBeChecked()
await expect(page.getByTestId('font-settings-provider-bunny')).not.toBeChecked()
await expect(page.getByTestId('font-settings-provider-fontshare')).not.toBeChecked()
await page.getByTestId('font-settings-toggle-online-fonts').click()
await expect(page.getByTestId('font-settings-toggle-online-fonts')).toHaveText('Enable')
await expect(page.getByTestId('font-settings-provider-google')).toBeDisabled()
await page.getByTestId('font-settings-toggle-online-fonts').click()
await expect(page.getByTestId('font-settings-toggle-online-fonts')).toHaveText('Disable')
await expect(page.getByTestId('font-settings-download-fallbacks')).toHaveCount(0)
await expect(page.getByTestId('font-settings-refresh-cache')).toHaveCount(0)
await expect(page.getByTestId('font-settings-clear-cache')).toHaveCount(0)