fix(beta3): correct installer layout and refine agent progress feedback
This commit is contained in:
1 parent
9731a7f082
commit
62e09f1f1d
25 files changed
+1775
-864
No files matched your search
@@ -97,7 +97,12 @@ impl LocalStorage {
|
||||
}
|
||||
|
||||
pub(crate) async fn read_ui(&self) -> SyncResult<Option<UiEnvelope>> {
|
||||
let value: Option<UiEnvelope> = self.read("device", "sync-ui-preferences").await?;
|
||||
let value: Option<UiEnvelope> =
|
||||
self.read("device", "sync-ui-preferences")
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
log_local_failure("ui_mirror_read", error);
|
||||
})?;
|
||||
if let Some(value) = &value {
|
||||
if value.schema_version != 1
|
||||
|| crate::cloud_sync_e2ee_protocol::types::UuidV4::parse(&value.revision).is_err()
|
||||
@@ -133,7 +138,10 @@ impl LocalStorage {
|
||||
.credentials
|
||||
.read_sync_secret(account.clone())
|
||||
.await
|
||||
.map_err(|_| error("secure_storage_denied"))?;
|
||||
.map_err(|_| {
|
||||
log::warn!("[e2ee-local] stage=wrapping_key_read code=secure_storage_denied");
|
||||
error("secure_storage_denied")
|
||||
})?;
|
||||
let bytes = match value {
|
||||
Some(value) => decode_key(value.expose_secret())?,
|
||||
None => {
|
||||
@@ -171,7 +179,10 @@ impl LocalStorage {
|
||||
SecretValue::new(URL_SAFE_NO_PAD.encode(&bytes[..])),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| error("secure_storage_denied"))?;
|
||||
.map_err(|_| {
|
||||
log::warn!("[e2ee-local] stage=wrapping_key_write code=secure_storage_denied");
|
||||
error("secure_storage_denied")
|
||||
})?;
|
||||
let verified = self
|
||||
.credentials
|
||||
.read_sync_secret(account)
|
||||
@@ -463,3 +474,18 @@ pub(crate) fn durable_create(path: &Path, bytes: &[u8]) -> SyncResult<bool> {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
result
|
||||
}
|
||||
|
||||
fn log_local_failure(stage: &'static str, failure: &crate::BackendError) {
|
||||
let code = match failure
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|details| details.get("reason"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
Some("secure_storage_denied") => "secure_storage_denied",
|
||||
Some("recovery_required") => "recovery_required",
|
||||
Some("secure_random_unavailable") => "secure_random_unavailable",
|
||||
_ => "local_storage_unavailable",
|
||||
};
|
||||
log::warn!("[e2ee-local] stage={stage} code={code}");
|
||||
}
|
||||
@@ -121,41 +121,41 @@ impl CoreSyncStore {
|
||||
Some(permit) => repositories.preferences.sync_snapshot(permit),
|
||||
None => repositories.preferences.sync_snapshot_readonly(),
|
||||
}
|
||||
.map_err(|_| DocumentError::CaptureFailed)?,
|
||||
.map_err(|error| capture_backend_error("preferences", error))?,
|
||||
);
|
||||
let dictionary = secret_rows(
|
||||
&match permit {
|
||||
Some(permit) => repositories.vocabulary.sync_snapshot(permit),
|
||||
None => repositories.vocabulary.list(),
|
||||
}
|
||||
.map_err(|_| DocumentError::CaptureFailed)?,
|
||||
.map_err(|error| capture_backend_error("dictionary", error))?,
|
||||
)?;
|
||||
let corrections = secret_rows(
|
||||
&match permit {
|
||||
Some(permit) => repositories.correction_rules.sync_snapshot(permit),
|
||||
None => repositories.correction_rules.list(),
|
||||
}
|
||||
.map_err(|_| DocumentError::CaptureFailed)?,
|
||||
.map_err(|error| capture_backend_error("corrections", error))?,
|
||||
)?;
|
||||
let history = secret_rows(
|
||||
&match permit {
|
||||
Some(permit) => repositories.history.sync_snapshot(permit),
|
||||
None => repositories.history.list(),
|
||||
}
|
||||
.map_err(|_| DocumentError::CaptureFailed)?,
|
||||
.map_err(|error| capture_backend_error("history", error))?,
|
||||
)?;
|
||||
let style_packs = match permit {
|
||||
Some(permit) => repositories.style_packs.sync_snapshot(permit),
|
||||
None => repositories.style_packs.sync_snapshot_readonly(),
|
||||
}
|
||||
.map_err(|_| DocumentError::CaptureFailed)?;
|
||||
.map_err(|error| capture_backend_error("style_packs", error))?;
|
||||
let activity = match permit {
|
||||
Some(permit) => repositories.activity.sync_records(permit),
|
||||
None => repositories.activity.sync_records_readonly(),
|
||||
}
|
||||
.map_err(|_| DocumentError::CaptureFailed)?;
|
||||
.map_err(|error| capture_backend_error("activity", error))?;
|
||||
let presets = crate::vocabulary::list_vocab_presets(&self.inner.data_dir)
|
||||
.map_err(|_| DocumentError::CaptureFailed)?;
|
||||
.map_err(|error| capture_backend_error("vocabulary_presets", error))?;
|
||||
let builtin_ids: Vec<_> = crate::vocabulary::builtin_vocab_presets()
|
||||
.into_iter()
|
||||
.map(|preset| preset.id)
|
||||
@@ -191,7 +191,7 @@ impl CoreSyncStore {
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|_| DocumentError::CaptureFailed)?;
|
||||
.map_err(|error| capture_backend_error("credentials", error))?;
|
||||
validate_credential_set(&credentials.channels, &credentials.credentials)?;
|
||||
Ok(ExportSnapshot {
|
||||
source_device: self.inner.device.clone(),
|
||||
@@ -238,3 +238,10 @@ impl CoreSyncStore {
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Values, paths, record IDs and error bodies may contain private user data.
|
||||
/// A capture diagnostic therefore records only an audited stage and enum code.
|
||||
fn capture_backend_error(stage: &'static str, error: crate::BackendError) -> DocumentError {
|
||||
log::warn!("[e2ee-capture] stage={stage} code={:?}", error.code);
|
||||
DocumentError::CaptureFailed
|
||||
}
|
||||
@@ -84,6 +84,7 @@ if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then
|
||||
DMG_LAYOUT_PYTHON="$DMG_LAYOUT_ENV_DIR/bin/python3"
|
||||
"$DMG_LAYOUT_PYTHON" -m pip install --quiet --disable-pip-version-check \
|
||||
--only-binary=:all: --no-deps --require-hashes -r scripts/macos-dmg-requirements.txt
|
||||
"$DMG_LAYOUT_PYTHON" scripts/macos-dmg-layout.test.py
|
||||
CI=true TAURI_BUNDLER_DMG_IGNORE_CI=false \
|
||||
OPENLESS_DMG_LAYOUT_ROOT="$PWD" OPENLESS_DMG_LAYOUT_PYTHON="$DMG_LAYOUT_PYTHON" \
|
||||
OPENLESS_DMG_LAYOUT_STAMP="$DMG_LAYOUT_ENV_DIR/layout-applied" \
|
||||
|
||||
@@ -17,6 +17,7 @@ from mac_alias import Alias
|
||||
|
||||
HDIUTIL = "/usr/bin/hdiutil"
|
||||
APP_ROOT = Path(__file__).resolve().parent.parent
|
||||
BACKGROUND_COLOR_KEYS = ("backgroundColorRed", "backgroundColorGreen", "backgroundColorBlue")
|
||||
|
||||
|
||||
def settings():
|
||||
@@ -111,6 +112,9 @@ def write_layout(volume):
|
||||
"gridOffsetX": 0.0, "gridOffsetY": 0.0, "gridSpacing": 64.0,
|
||||
"scrollPositionX": 0.0, "scrollPositionY": 0.0,
|
||||
"backgroundType": 2, "backgroundImageAlias": alias,
|
||||
# Finder requires the complete RGB tuple even for picture mode.
|
||||
# Without it macOS 27 ignores this icvp, including iconSize/alias.
|
||||
"backgroundColorRed": 1.0, "backgroundColorGreen": 1.0, "backgroundColorBlue": 1.0,
|
||||
}
|
||||
for name, key in [(app_name, "appPosition"), ("Applications", "applicationFolderPosition")]:
|
||||
store[name]["Iloc"] = (dmg[key]["x"], dmg[key]["y"])
|
||||
@@ -137,6 +141,8 @@ def verify_layout(image):
|
||||
with DSStore.open(str(volume / ".DS_Store"), "r") as store:
|
||||
icon = store["."]["icvp"]
|
||||
view = store["."]["bwsp"]
|
||||
if any(type(icon.get(key)) is not float or icon[key] != 1.0 for key in BACKGROUND_COLOR_KEYS):
|
||||
raise ValueError("DMG Finder icon view requires all three white RGB real components")
|
||||
position, size = dmg["windowPosition"], dmg["windowSize"]
|
||||
expected_bounds = "{{%d, %d}, {%d, %d}}" % (position["x"], position["y"], size["width"], size["height"])
|
||||
if view["WindowBounds"] != expected_bounds or any(view[key] for key in ["ShowToolbar", "ShowStatusBar", "ShowPathbar", "ShowSidebar", "ShowTabView"]):
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Regression for Finder's complete picture-mode icon-view schema.
|
||||
|
||||
The reference is Obsidian 1.13.7's shipped DS_Store and a macOS 27 Finder A/B:
|
||||
adding only the three RGB reals made the original background/icon layout render.
|
||||
These tests use the real DSStore codec; native mounts and image decoding are
|
||||
covered by that actual UDZO/Finder experiment, not emulated here.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from ds_store import DSStore
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"macos_dmg_layout", Path(__file__).with_name("macos-dmg-layout.py")
|
||||
)
|
||||
layout = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(layout)
|
||||
RGB = ("backgroundColorRed", "backgroundColorGreen", "backgroundColorBlue")
|
||||
|
||||
|
||||
class FinderPictureSchemaTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory(prefix="openless-dmg-schema-")
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.volume = self.root / "volume"
|
||||
contents = self.volume / "OpenLess.app/Contents"
|
||||
contents.mkdir(parents=True)
|
||||
(contents / "Info.plist").write_bytes(
|
||||
plistlib.dumps({"CFBundleShortVersionString": "1.2.3"})
|
||||
)
|
||||
(self.volume / "Applications").symlink_to("/Applications")
|
||||
background = self.root / "src-tauri/dmg/background.tiff"
|
||||
background.parent.mkdir(parents=True)
|
||||
background.write_bytes(b"controlled-background-fixture")
|
||||
self.dmg = {
|
||||
"windowPosition": {"x": 120, "y": 120},
|
||||
"windowSize": {"width": 768, "height": 512},
|
||||
"appPosition": {"x": 216, "y": 253},
|
||||
"applicationFolderPosition": {"x": 552, "y": 253},
|
||||
}
|
||||
self.style = {"iconSize": 128, "textSize": 14, "retinaBackground": "dmg/background.tiff"}
|
||||
mocks = contextlib.ExitStack()
|
||||
self.addCleanup(mocks.close)
|
||||
mocks.enter_context(patch.object(layout, "APP_ROOT", self.root))
|
||||
mocks.enter_context(patch.object(layout, "settings", return_value=(
|
||||
{"productName": "OpenLess", "version": "1.2.3"}, self.dmg, self.style
|
||||
)))
|
||||
mocks.enter_context(patch.object(layout.Alias, "for_file", return_value=
|
||||
SimpleNamespace(to_bytes=lambda: b"controlled-alias-fixture")))
|
||||
mocks.enter_context(patch.object(layout.Alias, "from_bytes", return_value=
|
||||
SimpleNamespace(target=SimpleNamespace(posix_path="/.background/installer-background.tiff"))))
|
||||
mocks.enter_context(patch.object(layout, "mounted", side_effect=
|
||||
lambda _image: contextlib.nullcontext(self.volume)))
|
||||
mocks.enter_context(patch.object(layout, "verify_retina_background"))
|
||||
|
||||
def verify(self):
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
layout.verify_layout(self.root / "fixture.dmg")
|
||||
|
||||
def replace_component(self, key, value, remove=False):
|
||||
layout.write_layout(self.volume)
|
||||
with DSStore.open(str(self.volume / ".DS_Store"), "r+") as store:
|
||||
icon = store["."]["icvp"]
|
||||
if remove:
|
||||
del icon[key]
|
||||
else:
|
||||
icon[key] = value
|
||||
store["."]["icvp"] = icon
|
||||
|
||||
def test_writer_preserves_version_one_and_complete_rgb_real_values(self):
|
||||
layout.write_layout(self.volume)
|
||||
with DSStore.open(str(self.volume / ".DS_Store"), "r") as store:
|
||||
icon = store["."]["icvp"]
|
||||
self.assertEqual(icon["viewOptionsVersion"], 1)
|
||||
self.assertEqual(store["."]["icvl"], (b"type", b"icnv"))
|
||||
for key in RGB:
|
||||
self.assertIs(type(icon[key]), float)
|
||||
self.assertEqual(icon[key], 1.0)
|
||||
self.verify()
|
||||
|
||||
def test_each_missing_rgb_component_is_rejected(self):
|
||||
for key in RGB:
|
||||
with self.subTest(key=key):
|
||||
self.replace_component(key, None, remove=True)
|
||||
with self.assertRaisesRegex(ValueError, "RGB real components"):
|
||||
self.verify()
|
||||
|
||||
def test_non_real_or_non_white_component_is_rejected(self):
|
||||
for value in (1, True, "1.0", 0.0):
|
||||
with self.subTest(value=value):
|
||||
self.replace_component("backgroundColorRed", value)
|
||||
with self.assertRaisesRegex(ValueError, "RGB real components"):
|
||||
self.verify()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+117
-1
@@ -8,6 +8,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
@@ -52,6 +62,19 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.6.2"
|
||||
@@ -109,12 +132,27 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -218,6 +256,17 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
@@ -229,6 +278,19 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -248,6 +310,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -327,6 +390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -1097,6 +1161,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openless-backend-tests"
|
||||
version = "0.1.0"
|
||||
@@ -1121,11 +1191,14 @@ name = "openless-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"base64",
|
||||
"bzip2 0.4.4",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"ferrous-opencc",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"log",
|
||||
"md-5",
|
||||
@@ -1141,8 +1214,10 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -1169,6 +1244,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
@@ -1260,6 +1346,17 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -1416,7 +1513,7 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"chacha20 0.10.2",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
@@ -2173,6 +2270,25 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -71,6 +71,7 @@ pub async fn qa_get_snapshot(window: Window, core: CoreState<'_>) -> Result<open
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn qa_window_set_expanded(window: Window, expanded: bool) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
if window.label() != "qa" { return Err("qa_window_required".into()); }
|
||||
let app = window.app_handle().clone();
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
|
||||
@@ -2949,14 +2949,117 @@ fn export_sync_credentials_root(
|
||||
snapshot
|
||||
.credentials
|
||||
.sort_by(|a, b| (a.namespace, &a.channel_id).cmp(&(b.namespace, &b.channel_id)));
|
||||
snapshot.validate().map_err(anyhow::Error::new)?;
|
||||
snapshot.validate().map_err(|error| {
|
||||
// Recheck the pure validator only to retain its fixed, value-free cause.
|
||||
// The original rejection and BackendError remain unchanged.
|
||||
if let Err(cause) = openless_core::cloud_sync_e2ee_documents::validate_credential_set(
|
||||
&snapshot.channels,
|
||||
&snapshot.credentials,
|
||||
) {
|
||||
log::warn!("[e2ee-capture] stage=credential_validation code={cause}");
|
||||
}
|
||||
anyhow::Error::new(error)
|
||||
})?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Classify typed causes without formatting an error, credential attribute or blob.
|
||||
fn sync_capture_read_error_code(error: &anyhow::Error) -> &'static str {
|
||||
let mut fallback = "native_store_failed";
|
||||
for cause in error.chain() {
|
||||
if let Some(code) = sync_keyring_error_code(cause) {
|
||||
if code != "keyring_platform_failure" {
|
||||
return code;
|
||||
}
|
||||
fallback = code;
|
||||
}
|
||||
if let Some(error) = cause.downcast_ref::<serde_json::Error>() {
|
||||
use serde_json::error::Category;
|
||||
return match error.classify() {
|
||||
Category::Io => error
|
||||
.io_error_kind()
|
||||
.map(sync_io_error_code)
|
||||
.unwrap_or("json_io"),
|
||||
Category::Syntax => "json_syntax",
|
||||
Category::Data => "json_data",
|
||||
Category::Eof => "json_eof",
|
||||
};
|
||||
}
|
||||
if let Some(error) = cause.downcast_ref::<std::io::Error>() {
|
||||
return sync_io_error_code(error.kind());
|
||||
}
|
||||
if cause.is::<std::str::Utf8Error>() || cause.is::<std::string::FromUtf8Error>() {
|
||||
return "invalid_utf8";
|
||||
}
|
||||
if let Some(error) = cause.downcast_ref::<openless_core::BackendError>() {
|
||||
use openless_core::BackendErrorCode;
|
||||
return match error.code {
|
||||
BackendErrorCode::PermissionDenied => "backend_permission_denied",
|
||||
BackendErrorCode::InvalidArgument => "backend_invalid_argument",
|
||||
BackendErrorCode::Persistence => "backend_persistence",
|
||||
BackendErrorCode::OutcomeUnknown => "backend_outcome_unknown",
|
||||
BackendErrorCode::Unsupported => "backend_unsupported",
|
||||
_ => "backend_failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
fallback
|
||||
}
|
||||
|
||||
fn sync_io_error_code(kind: std::io::ErrorKind) -> &'static str {
|
||||
use std::io::ErrorKind;
|
||||
match kind {
|
||||
ErrorKind::PermissionDenied => "io_permission_denied",
|
||||
ErrorKind::NotFound => "io_not_found",
|
||||
ErrorKind::InvalidData => "io_invalid_data",
|
||||
ErrorKind::InvalidInput => "io_invalid_input",
|
||||
ErrorKind::UnexpectedEof => "io_unexpected_eof",
|
||||
ErrorKind::TimedOut => "io_timed_out",
|
||||
ErrorKind::WouldBlock => "io_would_block",
|
||||
ErrorKind::Interrupted => "io_interrupted",
|
||||
_ => "io_other",
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_keyring_error_code(_cause: &(dyn std::error::Error + 'static)) -> Option<&'static str> {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
if let Some(error) = _cause.downcast_ref::<keyring::Error>() {
|
||||
return Some(match error {
|
||||
keyring::Error::NoStorageAccess(_) => "keyring_access_denied",
|
||||
keyring::Error::NoEntry => "keyring_no_entry",
|
||||
keyring::Error::BadEncoding(_) => "keyring_bad_encoding",
|
||||
keyring::Error::TooLong(_, _) => "keyring_attribute_too_long",
|
||||
keyring::Error::Invalid(_, _) => "keyring_invalid_attribute",
|
||||
keyring::Error::Ambiguous(_) => "keyring_ambiguous",
|
||||
keyring::Error::PlatformFailure(_) => "keyring_platform_failure",
|
||||
_ => "keyring_other",
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn capture_sync_credentials_with(
|
||||
load: impl FnOnce() -> Result<CredsRoot>,
|
||||
) -> Result<openless_core::credentials::SyncCredentials> {
|
||||
export_sync_credentials_root(&load()?)
|
||||
let root = load().map_err(|error| {
|
||||
let code = sync_capture_read_error_code(&error);
|
||||
log::warn!("[e2ee-capture] stage=credential_load code={code}");
|
||||
error
|
||||
})?;
|
||||
export_sync_credentials_root(&root).map_err(|error| {
|
||||
// Metadata counts identify invalid legacy state without exposing channel names,
|
||||
// accounts, secret values, provider IDs, endpoints, or underlying error bodies.
|
||||
let disabled_active = usize::from(root.providers.asr.get(&root.active.asr).is_some_and(|entry| !entry.channel.enabled))
|
||||
+ usize::from(root.providers.llm.get(&root.active.llm).is_some_and(|entry| !entry.channel.enabled))
|
||||
+ usize::from(root.omni.providers.get(&root.omni.active).is_some_and(|entry| !entry.channel.enabled));
|
||||
let mismatched_omni = root.omni.providers.iter().filter(|(id,entry)| entry.channel.providerType.as_ref().is_some_and(|provider|provider!=*id)).count();
|
||||
let invalid_id = |value: &str| value.is_empty() || value.len() > 512 || value.chars().any(|ch| ch.is_control() || ch == '/' || ch == '\\');
|
||||
let invalid_identities = root.providers.asr.iter().filter(|(id,entry)|invalid_id(id) || entry.channel.providerType.as_deref().is_some_and(invalid_id)).count()
|
||||
+ root.providers.llm.iter().filter(|(id,entry)|invalid_id(id) || entry.channel.providerType.as_deref().is_some_and(invalid_id)).count()
|
||||
+ root.omni.providers.iter().filter(|(id,entry)|invalid_id(id) || entry.channel.providerType.as_deref().is_some_and(invalid_id)).count();
|
||||
log::warn!("[e2ee-capture] stage=credential_projection code=invalid_snapshot disabled_active={disabled_active} mismatched_omni={mismatched_omni} invalid_identities={invalid_identities}");
|
||||
error
|
||||
})
|
||||
}
|
||||
|
||||
fn restored_channel_meta(channel: &openless_core::credentials::SyncChannel) -> ChannelMeta {
|
||||
@@ -5601,3 +5704,106 @@ mod encrypted_sync_tests {
|
||||
assert!(validate_sync_key(&openless_core::SecretValue::new("not-a-key")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sync_capture_diagnostic_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn typed_diagnostics_never_expose_error_bodies_or_attributes() {
|
||||
let private = "fixture-private-value-never-log";
|
||||
let cases: Vec<(anyhow::Error, &str)> = vec![
|
||||
(
|
||||
anyhow::Error::new(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
private,
|
||||
))
|
||||
.context(private),
|
||||
"io_permission_denied",
|
||||
),
|
||||
(
|
||||
anyhow::Error::new(
|
||||
serde_json::from_str::<u32>(&format!("\"{private}\"")).unwrap_err(),
|
||||
)
|
||||
.context(private),
|
||||
"json_data",
|
||||
),
|
||||
(
|
||||
anyhow::Error::new(serde_json::from_str::<serde_json::Value>("]").unwrap_err()),
|
||||
"json_syntax",
|
||||
),
|
||||
(
|
||||
anyhow::Error::new(serde_json::from_str::<serde_json::Value>("{").unwrap_err()),
|
||||
"json_eof",
|
||||
),
|
||||
(
|
||||
anyhow::Error::new(String::from_utf8(vec![255]).unwrap_err()),
|
||||
"invalid_utf8",
|
||||
),
|
||||
(anyhow::anyhow!(private), "native_store_failed"),
|
||||
];
|
||||
for (error, expected) in cases {
|
||||
let code = sync_capture_read_error_code(&error);
|
||||
assert_eq!(code, expected);
|
||||
assert!(!code.contains(private));
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[test]
|
||||
fn keyring_causes_are_classified_without_formatting_secret_material() {
|
||||
let private = "fixture-private-value-never-log";
|
||||
let cases: Vec<(keyring::Error, &str)> = vec![
|
||||
(
|
||||
keyring::Error::NoStorageAccess(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
private,
|
||||
))),
|
||||
"keyring_access_denied",
|
||||
),
|
||||
(
|
||||
keyring::Error::PlatformFailure(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
private,
|
||||
))),
|
||||
"io_timed_out",
|
||||
),
|
||||
(
|
||||
keyring::Error::BadEncoding(private.as_bytes().to_vec()),
|
||||
"keyring_bad_encoding",
|
||||
),
|
||||
(
|
||||
keyring::Error::Invalid(private.into(), private.into()),
|
||||
"keyring_invalid_attribute",
|
||||
),
|
||||
(keyring::Error::NoEntry, "keyring_no_entry"),
|
||||
];
|
||||
for (error, expected) in cases {
|
||||
assert_eq!(
|
||||
sync_capture_read_error_code(&anyhow::Error::new(error).context(private)),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn projection_diagnostic_does_not_relax_omni_identity_validation() {
|
||||
let mut root = CredsRoot::default();
|
||||
root.omni.active = "custom".into();
|
||||
root.omni.providers.insert(
|
||||
"custom".into(),
|
||||
CredsOmniEntry {
|
||||
channel: ChannelMeta {
|
||||
providerType: Some("different".into()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let error = export_sync_credentials_root(&root).unwrap_err();
|
||||
assert_eq!(
|
||||
error
|
||||
.downcast_ref::<openless_core::BackendError>()
|
||||
.unwrap()
|
||||
.code,
|
||||
openless_core::BackendErrorCode::InvalidArgument
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,7 @@ fn validate_side_binding(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
impl SideAwareComboMonitor {
|
||||
/// Update the existing route without replacing its handle or event sender.
|
||||
/// Used when a failed native-key switch restores an already-live shortcut.
|
||||
@@ -636,7 +636,7 @@ pub mod platform {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
#[test]
|
||||
fn restoring_live_side_binding_preserves_its_event_route() {
|
||||
use std::sync::mpsc;
|
||||
@@ -670,7 +670,7 @@ mod tests {
|
||||
press_and_release("D");
|
||||
assert!(monitor
|
||||
.update_binding(ShortcutBinding {
|
||||
primary: "F21".into(),
|
||||
primary: "F25".into(),
|
||||
modifiers: vec!["ctrl-right".into()]
|
||||
})
|
||||
.is_err());
|
||||
|
||||
@@ -2,6 +2,22 @@ import type { zhCN } from './zh-CN';
|
||||
|
||||
export const de: typeof zhCN = {
|
||||
cloudSyncE2ee: {
|
||||
protocolTitle: 'Cloud-Sync: Vereinbarung und Datenschutz',
|
||||
protocolIntro:
|
||||
'OpenLess und seine unabhängigen Entwickler respektieren Ihre Privatsphäre und schützen Ihre Daten. Lesen Sie diese Hinweise, bevor Sie fortfahren.',
|
||||
protocolPasswordTitle: 'Synchronisierungspasswort sicher aufbewahren',
|
||||
protocolPassword:
|
||||
'Passwort und Entschlüsselungsschlüssel werden lokal verwendet und nicht an den Sync-Dienst gesendet. Geht das Passwort verloren und kann kein Gerät das Backup mehr entsperren, können wir den Inhalt nicht wiederherstellen.',
|
||||
protocolEncryptionTitle: 'Vor dem Hochladen lokal verschlüsseln',
|
||||
protocolEncryption:
|
||||
'Einstellungen, API-Schlüssel und Textverlauf werden auf diesem Gerät verschlüsselt. Backup-Inhalte werden als Geheimtext übertragen und gespeichert. Der Sync-Dienst kann Ihre API-Schlüssel daraus weder lesen noch verwenden und führt damit keine Modellanfragen aus.',
|
||||
protocolExcludedTitle: 'Anmeldestatus und sichtbare Metadaten',
|
||||
protocolExcluded:
|
||||
'Das Backup enthält weder Sync-Passwort noch Entschlüsselungsschlüssel, von OpenLess verwaltete GitHub/OAuth-Anmeldedaten, Zugriffstoken oder private Geräteschlüssel. Die Kontoanmeldung verarbeitet notwendige Zugangsdaten separat. Kontokennung, Größe, Versionen und Sync-Zeiten bleiben für den Server sichtbar.',
|
||||
protocolCheck:
|
||||
'Ich habe diese Vereinbarung gelesen, verstehe den Umfang und bewahre mein Sync-Passwort sicher auf.',
|
||||
protocolBack: 'Zurück zum Umfang',
|
||||
protocolConfirm: 'Gelesen und bestätigen',
|
||||
setupPromptTitle: 'Einrichtung verschlüsselt sichern?',
|
||||
setupPromptBody:
|
||||
'Die Dienste sind eingerichtet. Einstellungen, API-Schlüssel und Textverlauf werden auf diesem Gerät verschlüsselt. Erst nach Ihrer Zustimmung und Aktivierung werden Daten hochgeladen.',
|
||||
@@ -311,6 +327,26 @@ export const de: typeof zhCN = {
|
||||
editInstructionMode: 'Bearbeitungsanweisung',
|
||||
},
|
||||
lessComputer: {
|
||||
activity: {
|
||||
process: 'Ablauf',
|
||||
count: '{{count}} Aktivitäten',
|
||||
count_one: '{{count}} Aktivität',
|
||||
count_other: '{{count}} Aktivitäten',
|
||||
finished: 'Abgeschlossen',
|
||||
stopped: 'Gestoppt',
|
||||
search: 'Suche',
|
||||
searchRunning: 'Wird gesucht…',
|
||||
read: 'Lesen',
|
||||
readRunning: 'Wird gelesen…',
|
||||
command: 'Befehle',
|
||||
commandRunning: 'Befehle werden ausgeführt…',
|
||||
edit: 'Bearbeitung',
|
||||
editRunning: 'Wird bearbeitet…',
|
||||
web: 'Webseiten',
|
||||
webRunning: 'Webseiten werden gelesen…',
|
||||
other: 'Werkzeuge',
|
||||
otherRunning: 'In Arbeit…',
|
||||
},
|
||||
desktop: {
|
||||
approvedSubmitted: 'Freigabe gesendet',
|
||||
deniedSubmitted: 'Ablehnung gesendet',
|
||||
|
||||
@@ -5,6 +5,22 @@ import type { zhCN } from './zh-CN';
|
||||
// Type-level guarantee that en mirrors the zh-CN shape.
|
||||
export const en: typeof zhCN = {
|
||||
cloudSyncE2ee: {
|
||||
protocolTitle: 'Cloud sync agreement and privacy notice',
|
||||
protocolIntro:
|
||||
'OpenLess and its independent developers respect your privacy and work to protect your data. Read this notice before continuing.',
|
||||
protocolPasswordTitle: 'Keep your sync password safe',
|
||||
protocolPassword:
|
||||
'Your sync password and decryption key are used locally and are never sent to the sync service. If you lose the password and have no device that can still unlock the backup, we cannot recover its contents.',
|
||||
protocolEncryptionTitle: 'Encrypt on this device before uploading',
|
||||
protocolEncryption:
|
||||
'Settings, service API keys and text history are encrypted locally. Backup content is sent and stored as ciphertext. The sync service cannot read or use your API keys from it, and does not use this content to run model requests.',
|
||||
protocolExcludedTitle: 'Sign-in state and visible metadata',
|
||||
protocolExcluded:
|
||||
'The backup excludes your sync password, decryption keys, OpenLess-managed GitHub/OAuth sign-in state, access tokens and device private keys. Account authentication handles necessary credentials separately. The server can still see account identifiers, ciphertext size, versions and sync times.',
|
||||
protocolCheck:
|
||||
'I have read the agreement and privacy notice, understand the scope, and will keep my sync password safe.',
|
||||
protocolBack: 'Back to sync scope',
|
||||
protocolConfirm: 'I have read and confirm',
|
||||
setupPromptTitle: 'Back up your setup securely?',
|
||||
setupPromptBody:
|
||||
'Your service setup is complete. Settings, service API keys and text history are encrypted on this device. Uploads start only after you review the scope and enable sync.',
|
||||
@@ -307,6 +323,26 @@ export const en: typeof zhCN = {
|
||||
editInstructionMode: 'Edit instruction',
|
||||
},
|
||||
lessComputer: {
|
||||
activity: {
|
||||
process: 'Activity',
|
||||
count: '{{count}} activities',
|
||||
count_one: '{{count}} activity',
|
||||
count_other: '{{count}} activities',
|
||||
finished: 'Finished',
|
||||
stopped: 'Stopped',
|
||||
search: 'Search',
|
||||
searchRunning: 'Searching…',
|
||||
read: 'Read',
|
||||
readRunning: 'Reading…',
|
||||
command: 'Commands',
|
||||
commandRunning: 'Running commands…',
|
||||
edit: 'Edits',
|
||||
editRunning: 'Editing…',
|
||||
web: 'Web',
|
||||
webRunning: 'Browsing…',
|
||||
other: 'Tools',
|
||||
otherRunning: 'Working…',
|
||||
},
|
||||
desktop: {
|
||||
approvedSubmitted: 'Approval sent',
|
||||
deniedSubmitted: 'Denial sent',
|
||||
|
||||
@@ -2,6 +2,22 @@ import type { zhCN } from './zh-CN';
|
||||
|
||||
export const es: typeof zhCN = {
|
||||
cloudSyncE2ee: {
|
||||
protocolTitle: 'Acuerdo de sincronización y privacidad',
|
||||
protocolIntro:
|
||||
'OpenLess y sus desarrolladores independientes respetan tu privacidad y protegen tus datos. Lee este aviso antes de continuar.',
|
||||
protocolPasswordTitle: 'Guarda tu contraseña de sincronización',
|
||||
protocolPassword:
|
||||
'La contraseña y la clave de descifrado se usan localmente y no se envían al servicio. Si pierdes la contraseña y ningún dispositivo puede desbloquear la copia, no podremos recuperar su contenido.',
|
||||
protocolEncryptionTitle: 'Cifrado local antes de subir los datos',
|
||||
protocolEncryption:
|
||||
'Los ajustes, claves API e historial de texto se cifran en este dispositivo. La copia se envía y almacena cifrada. El servicio no puede leer ni utilizar tus claves API y no usa estos datos para ejecutar solicitudes a modelos.',
|
||||
protocolExcludedTitle: 'Inicio de sesión y metadatos visibles',
|
||||
protocolExcluded:
|
||||
'La copia excluye la contraseña, claves de descifrado, estado de GitHub/OAuth gestionado por OpenLess, tokens de acceso y claves privadas del dispositivo. La autenticación de la cuenta trata sus credenciales por separado. El servidor aún puede ver la cuenta, el tamaño del cifrado, las versiones y las fechas de sincronización.',
|
||||
protocolCheck:
|
||||
'He leído el acuerdo y el aviso, entiendo el alcance y guardaré mi contraseña de forma segura.',
|
||||
protocolBack: 'Volver al alcance',
|
||||
protocolConfirm: 'Leído y confirmado',
|
||||
setupPromptTitle: '¿Guardar una copia cifrada?',
|
||||
setupPromptBody:
|
||||
'Los servicios están configurados. Los ajustes, claves de API e historial de texto se cifran en este dispositivo. Solo se suben tras revisar el alcance y activar la sincronización.',
|
||||
@@ -309,6 +325,26 @@ export const es: typeof zhCN = {
|
||||
editInstructionMode: 'Instrucción de edición',
|
||||
},
|
||||
lessComputer: {
|
||||
activity: {
|
||||
process: 'Actividad',
|
||||
count: '{{count}} actividades',
|
||||
count_one: '{{count}} actividad',
|
||||
count_other: '{{count}} actividades',
|
||||
finished: 'Completado',
|
||||
stopped: 'Detenido',
|
||||
search: 'Búsqueda',
|
||||
searchRunning: 'Buscando…',
|
||||
read: 'Lectura',
|
||||
readRunning: 'Leyendo…',
|
||||
command: 'Comandos',
|
||||
commandRunning: 'Ejecutando comandos…',
|
||||
edit: 'Edición',
|
||||
editRunning: 'Editando…',
|
||||
web: 'Web',
|
||||
webRunning: 'Consultando páginas…',
|
||||
other: 'Herramientas',
|
||||
otherRunning: 'Procesando…',
|
||||
},
|
||||
desktop: {
|
||||
approvedSubmitted: 'Aprobación enviada',
|
||||
deniedSubmitted: 'Rechazo enviado',
|
||||
|
||||
@@ -2,6 +2,22 @@ import type { zhCN } from './zh-CN';
|
||||
|
||||
export const fr: typeof zhCN = {
|
||||
cloudSyncE2ee: {
|
||||
protocolTitle: 'Accord de synchronisation et confidentialité',
|
||||
protocolIntro:
|
||||
'OpenLess et ses développeurs indépendants respectent votre vie privée et protègent vos données. Lisez cette notice avant de continuer.',
|
||||
protocolPasswordTitle: 'Conservez votre mot de passe',
|
||||
protocolPassword:
|
||||
'Le mot de passe et la clé de déchiffrement restent utilisés localement et ne sont jamais envoyés au service. Si vous perdez le mot de passe et qu’aucun appareil ne peut déverrouiller la sauvegarde, nous ne pouvons pas récupérer son contenu.',
|
||||
protocolEncryptionTitle: 'Chiffrement local avant l’envoi',
|
||||
protocolEncryption:
|
||||
'Réglages, clés API et historique texte sont chiffrés sur cet appareil. La sauvegarde est transmise et stockée chiffrée. Le service ne peut ni lire ni utiliser vos clés API et n’emploie pas ces contenus pour appeler des modèles.',
|
||||
protocolExcludedTitle: 'Connexion et métadonnées visibles',
|
||||
protocolExcluded:
|
||||
'La sauvegarde exclut le mot de passe, les clés de déchiffrement, l’état GitHub/OAuth géré par OpenLess, les jetons d’accès et les clés privées de l’appareil. L’authentification du compte traite séparément les identifiants nécessaires. Le serveur voit encore le compte, la taille chiffrée, les versions et les dates de synchronisation.',
|
||||
protocolCheck:
|
||||
'J’ai lu l’accord et la notice, je comprends le périmètre et conserverai mon mot de passe en sécurité.',
|
||||
protocolBack: 'Revenir au périmètre',
|
||||
protocolConfirm: 'Lu et confirmé',
|
||||
setupPromptTitle: 'Sauvegarder votre configuration ?',
|
||||
setupPromptBody:
|
||||
'Les services sont configurés. Réglages, clés API et historique texte sont chiffrés sur cet appareil. L’envoi commence uniquement après examen du périmètre et activation.',
|
||||
@@ -312,6 +328,26 @@ export const fr: typeof zhCN = {
|
||||
editInstructionMode: 'Instruction de modification',
|
||||
},
|
||||
lessComputer: {
|
||||
activity: {
|
||||
process: 'Activité',
|
||||
count: '{{count}} activités',
|
||||
count_one: '{{count}} activité',
|
||||
count_other: '{{count}} activités',
|
||||
finished: 'Terminé',
|
||||
stopped: 'Arrêté',
|
||||
search: 'Recherche',
|
||||
searchRunning: 'Recherche en cours…',
|
||||
read: 'Lecture',
|
||||
readRunning: 'Lecture en cours…',
|
||||
command: 'Commandes',
|
||||
commandRunning: 'Exécution des commandes…',
|
||||
edit: 'Modifications',
|
||||
editRunning: 'Modification en cours…',
|
||||
web: 'Web',
|
||||
webRunning: 'Navigation en cours…',
|
||||
other: 'Outils',
|
||||
otherRunning: 'Traitement en cours…',
|
||||
},
|
||||
desktop: {
|
||||
approvedSubmitted: 'Approbation envoyée',
|
||||
deniedSubmitted: 'Refus envoyé',
|
||||
|
||||
@@ -7,6 +7,22 @@ import { en } from './en';
|
||||
export const ja: typeof zhCN = {
|
||||
...en,
|
||||
cloudSyncE2ee: {
|
||||
protocolTitle: 'クラウド同期の同意事項とプライバシー',
|
||||
protocolIntro:
|
||||
'OpenLess と開発者はプライバシーを尊重し、データの保護に取り組んでいます。続行する前に、以下をご確認ください。',
|
||||
protocolPasswordTitle: '同期パスワードを大切に保管してください',
|
||||
protocolPassword:
|
||||
'同期パスワードと復号キーは端末内で使用され、同期サービスには送信されません。パスワードを紛失し、解除できる端末もない場合、内容を復元することはできません。',
|
||||
protocolEncryptionTitle: '端末で暗号化してから送信',
|
||||
protocolEncryption:
|
||||
'設定、API キー、テキスト履歴はこの端末で暗号化されます。バックアップは暗号文として送信・保存されます。同期サービスはその内容から API キーを読み取ったり使用したりせず、モデルへのリクエストにも利用しません。',
|
||||
protocolExcludedTitle: 'ログイン状態と確認可能な情報',
|
||||
protocolExcluded:
|
||||
'同期パスワード、復号キー、OpenLess が管理する GitHub/OAuth ログイン状態、アクセストークン、端末の秘密鍵はバックアップに含まれません。アカウント認証には必要な認証情報を別途使用します。サーバーにはアカウント識別子、暗号文のサイズ、版、同期日時が見えます。',
|
||||
protocolCheck:
|
||||
'同意事項とプライバシーの説明を読み、対象を理解しました。同期パスワードを適切に保管します。',
|
||||
protocolBack: '同期対象に戻る',
|
||||
protocolConfirm: '内容を確認して同意',
|
||||
setupPromptTitle: '設定を暗号化してバックアップしますか?',
|
||||
setupPromptBody:
|
||||
'サービスの設定が完了しました。設定、API キー、テキスト履歴はこの端末で暗号化されます。対象を確認して同期を有効にした後にのみアップロードされます。',
|
||||
@@ -298,6 +314,26 @@ export const ja: typeof zhCN = {
|
||||
editInstructionMode: '編集指示',
|
||||
},
|
||||
lessComputer: {
|
||||
activity: {
|
||||
process: '処理の流れ',
|
||||
count: '{{count}} 件の処理',
|
||||
count_one: '{{count}} 件の処理',
|
||||
count_other: '{{count}} 件の処理',
|
||||
finished: '完了',
|
||||
stopped: '停止',
|
||||
search: '検索',
|
||||
searchRunning: '検索中…',
|
||||
read: '読み取り',
|
||||
readRunning: '読み取り中…',
|
||||
command: 'コマンド',
|
||||
commandRunning: 'コマンドを実行中…',
|
||||
edit: '編集',
|
||||
editRunning: '編集中…',
|
||||
web: 'ウェブ',
|
||||
webRunning: 'ウェブページを確認中…',
|
||||
other: 'ツール',
|
||||
otherRunning: '処理中…',
|
||||
},
|
||||
desktop: {
|
||||
approvedSubmitted: '許可を送信しました',
|
||||
deniedSubmitted: '拒否を送信しました',
|
||||
|
||||
@@ -7,6 +7,22 @@ import { en } from './en';
|
||||
export const ko: typeof zhCN = {
|
||||
...en,
|
||||
cloudSyncE2ee: {
|
||||
protocolTitle: '클라우드 동기화 약관 및 개인정보 안내',
|
||||
protocolIntro:
|
||||
'OpenLess와 독립 개발자는 개인정보를 존중하고 데이터를 보호하기 위해 노력합니다. 계속하기 전에 아래 안내를 읽어 주세요.',
|
||||
protocolPasswordTitle: '동기화 비밀번호를 안전하게 보관하세요',
|
||||
protocolPassword:
|
||||
'동기화 비밀번호와 복호화 키는 기기에서만 사용되며 동기화 서비스로 전송되지 않습니다. 비밀번호를 잃고 백업을 잠금 해제할 기기도 없다면 내용을 복구할 수 없습니다.',
|
||||
protocolEncryptionTitle: '기기에서 암호화한 후 업로드',
|
||||
protocolEncryption:
|
||||
'설정, API 키, 텍스트 기록은 이 기기에서 암호화됩니다. 백업은 암호문으로 전송되고 저장됩니다. 동기화 서비스는 이를 통해 API 키를 읽거나 사용하지 못하며 모델 요청에도 이용하지 않습니다.',
|
||||
protocolExcludedTitle: '로그인 상태와 확인 가능한 정보',
|
||||
protocolExcluded:
|
||||
'동기화 비밀번호, 복호화 키, OpenLess가 관리하는 GitHub/OAuth 로그인 상태, 액세스 토큰, 기기 개인 키는 백업에 포함되지 않습니다. 계정 인증에는 필요한 자격 증명을 별도로 사용합니다. 서버는 계정 식별자, 암호문 크기, 버전 및 동기화 시간을 볼 수 있습니다.',
|
||||
protocolCheck:
|
||||
'약관과 개인정보 안내를 읽고 범위를 이해했으며 동기화 비밀번호를 안전하게 보관하겠습니다.',
|
||||
protocolBack: '동기화 범위로 돌아가기',
|
||||
protocolConfirm: '읽었으며 동의합니다',
|
||||
setupPromptTitle: '설정을 암호화해 백업할까요?',
|
||||
setupPromptBody:
|
||||
'서비스 설정이 완료되었습니다. 설정, API 키, 텍스트 기록은 이 기기에서 암호화됩니다. 범위를 확인하고 동기화를 켠 후에만 업로드됩니다.',
|
||||
@@ -294,6 +310,26 @@ export const ko: typeof zhCN = {
|
||||
editInstructionMode: '편집 지시',
|
||||
},
|
||||
lessComputer: {
|
||||
activity: {
|
||||
process: '처리 과정',
|
||||
count: '활동 {{count}}개',
|
||||
count_one: '활동 {{count}}개',
|
||||
count_other: '활동 {{count}}개',
|
||||
finished: '완료',
|
||||
stopped: '중지됨',
|
||||
search: '검색',
|
||||
searchRunning: '검색 중…',
|
||||
read: '읽기',
|
||||
readRunning: '읽는 중…',
|
||||
command: '명령',
|
||||
commandRunning: '명령 실행 중…',
|
||||
edit: '편집',
|
||||
editRunning: '편집 중…',
|
||||
web: '웹',
|
||||
webRunning: '웹페이지 확인 중…',
|
||||
other: '도구',
|
||||
otherRunning: '처리 중…',
|
||||
},
|
||||
desktop: {
|
||||
approvedSubmitted: '허용을 제출했습니다',
|
||||
deniedSubmitted: '거부를 제출했습니다',
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
|
||||
export const zhCN = {
|
||||
cloudSyncE2ee: {
|
||||
protocolTitle: '云同步协议与隐私提醒',
|
||||
protocolIntro:
|
||||
'OpenLess 及个人开发者尊重您的隐私,并致力于保护您的资料。请阅读以下说明,再决定是否继续。',
|
||||
protocolPasswordTitle: '请妥善保存同步密码',
|
||||
protocolPassword:
|
||||
'同步密码和解密密钥仅用于本机加密与解锁,不会发送给云同步服务。若密码遗失且没有仍可解锁的设备,我们无法找回加密内容。',
|
||||
protocolEncryptionTitle: '先在本机加密,再上传',
|
||||
protocolEncryption:
|
||||
'配置、服务 API 密钥和历史文本会先在本机加密,发往云端和保存在云端的备份内容均为密文。云同步服务无法据此读取或调用您的服务 API 密钥,也不会使用这些内容执行模型请求。',
|
||||
protocolExcludedTitle: '登录状态与可见信息',
|
||||
protocolExcluded:
|
||||
'同步密码、解密密钥、OpenLess 管理的 GitHub/OAuth 登录状态、访问令牌及设备私钥不进入备份。账号认证会单独处理必要凭据;服务器仍可见账号标识、密文大小、版本及同步时间。',
|
||||
protocolCheck: '我已阅读协议与隐私提醒,了解同步范围,并会妥善保管同步密码。',
|
||||
protocolBack: '返回同步范围',
|
||||
protocolConfirm: '我已阅读并确认',
|
||||
setupPromptTitle: '为配置开启加密备份?',
|
||||
setupPromptBody:
|
||||
'服务配置已完成。配置、服务 API 密钥和历史文本会先在此设备加密。查看同步范围并主动开启后,才会上传。',
|
||||
@@ -286,6 +301,26 @@ export const zhCN = {
|
||||
editInstructionMode: '编辑指令',
|
||||
},
|
||||
lessComputer: {
|
||||
activity: {
|
||||
process: '处理过程',
|
||||
count: '{{count}} 项活动',
|
||||
count_one: '{{count}} 项活动',
|
||||
count_other: '{{count}} 项活动',
|
||||
finished: '已完成',
|
||||
stopped: '已停止',
|
||||
search: '搜索',
|
||||
searchRunning: '正在搜索…',
|
||||
read: '读取',
|
||||
readRunning: '正在读取…',
|
||||
command: '运行命令',
|
||||
commandRunning: '正在运行命令…',
|
||||
edit: '编辑',
|
||||
editRunning: '正在编辑…',
|
||||
web: '查看网页',
|
||||
webRunning: '正在查看网页…',
|
||||
other: '工具处理',
|
||||
otherRunning: '正在处理…',
|
||||
},
|
||||
desktop: {
|
||||
approvedSubmitted: '已提交允许',
|
||||
deniedSubmitted: '已提交拒绝',
|
||||
|
||||
@@ -3,6 +3,21 @@ import type { zhCN } from './zh-CN';
|
||||
// 繁體中文資源,與其餘七種語言共用同一組文案 key。
|
||||
export const zhTW: typeof zhCN = {
|
||||
cloudSyncE2ee: {
|
||||
protocolTitle: '雲端同步協議與隱私提醒',
|
||||
protocolIntro:
|
||||
'OpenLess 及個人開發者尊重您的隱私,並致力於保護您的資料。請閱讀以下說明,再決定是否繼續。',
|
||||
protocolPasswordTitle: '請妥善保存同步密碼',
|
||||
protocolPassword:
|
||||
'同步密碼及解密金鑰僅用於本機加密與解鎖,不會傳送給雲端同步服務。若密碼遺失且沒有仍可解鎖的裝置,我們無法找回加密內容。',
|
||||
protocolEncryptionTitle: '先在本機加密,再上傳',
|
||||
protocolEncryption:
|
||||
'設定、服務 API 金鑰及歷史文字會先在本機加密,傳送與儲存在雲端的備份內容均為密文。雲端同步服務無法據此讀取或使用您的 API 金鑰,也不會使用這些內容執行模型請求。',
|
||||
protocolExcludedTitle: '登入狀態與可見資訊',
|
||||
protocolExcluded:
|
||||
'同步密碼、解密金鑰、OpenLess 管理的 GitHub/OAuth 登入狀態、存取權杖及裝置私鑰不進入備份。帳號驗證會另外處理必要憑證;伺服器仍可見帳號識別碼、密文大小、版本及同步時間。',
|
||||
protocolCheck: '我已閱讀協議與隱私提醒,了解同步範圍,並會妥善保管同步密碼。',
|
||||
protocolBack: '返回同步範圍',
|
||||
protocolConfirm: '我已閱讀並確認',
|
||||
setupPromptTitle: '為設定開啟加密備份?',
|
||||
setupPromptBody:
|
||||
'服務設定已完成。設定、服務 API 金鑰及歷史文字會先在此裝置加密。檢視同步範圍並主動開啟後,才會上傳。',
|
||||
@@ -286,6 +301,26 @@ export const zhTW: typeof zhCN = {
|
||||
editInstructionMode: '編輯指令',
|
||||
},
|
||||
lessComputer: {
|
||||
activity: {
|
||||
process: '處理過程',
|
||||
count: '{{count}} 項活動',
|
||||
count_one: '{{count}} 項活動',
|
||||
count_other: '{{count}} 項活動',
|
||||
finished: '已完成',
|
||||
stopped: '已停止',
|
||||
search: '搜尋',
|
||||
searchRunning: '正在搜尋…',
|
||||
read: '讀取',
|
||||
readRunning: '正在讀取…',
|
||||
command: '執行指令',
|
||||
commandRunning: '正在執行指令…',
|
||||
edit: '編輯',
|
||||
editRunning: '正在編輯…',
|
||||
web: '檢視網頁',
|
||||
webRunning: '正在檢視網頁…',
|
||||
other: '工具處理',
|
||||
otherRunning: '正在處理…',
|
||||
},
|
||||
desktop: {
|
||||
approvedSubmitted: '已提交允許',
|
||||
deniedSubmitted: '已提交拒絕',
|
||||
|
||||
@@ -53,7 +53,8 @@ async function install(): Promise<void> {
|
||||
a.account?.githubId === b.account?.githubId && a.vaultId === b.vaultId;
|
||||
const belongs = (state: Status, event: Restored) =>
|
||||
state.account?.githubId === event.accountId && state.vaultId === event.vaultId;
|
||||
const failed = () => window.dispatchEvent(new Event('openless:sync-ui-persistence-failed'));
|
||||
const failed = (error: unknown) =>
|
||||
window.dispatchEvent(new CustomEvent('openless:sync-ui-persistence-failed', { detail: error }));
|
||||
const enqueue = (work: () => Promise<void>): Promise<void> => {
|
||||
const next = queue.catch(() => {}).then(work);
|
||||
queue = next.catch(failed);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { groupToolActivities, toolActivityCategory } from './lessComputerToolActivity';
|
||||
|
||||
function equal(actual: unknown, expected: unknown, name: string) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
||||
throw new Error(`${name}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`);
|
||||
}
|
||||
|
||||
for (const [name, category] of [
|
||||
['Grep', 'search'],
|
||||
['mcp__files__read_file', 'read'],
|
||||
['Bash', 'command'],
|
||||
['apply_patch', 'edit'],
|
||||
['WebFetch', 'web'],
|
||||
['provider.some_unknown_tool', 'other'],
|
||||
])
|
||||
equal(toolActivityCategory(name), category, name);
|
||||
|
||||
const calls = [
|
||||
{ name: 'Read', running: false },
|
||||
{ name: 'Bash', running: false },
|
||||
{ name: 'Bash', running: false },
|
||||
{ name: 'Bash', running: true },
|
||||
];
|
||||
const grouped = groupToolActivities(calls, true);
|
||||
equal(
|
||||
grouped.map(({ category, state }) => [category, state]),
|
||||
[
|
||||
['read', 'finished'],
|
||||
['command', 'active'],
|
||||
],
|
||||
'consecutive phase grouping',
|
||||
);
|
||||
equal(grouped[1].names, [{ name: 'Bash', count: 3 }], 'dense actual calls retain their count');
|
||||
equal(
|
||||
grouped.flatMap((group) => group.tools),
|
||||
calls,
|
||||
'every real tool event remains available',
|
||||
);
|
||||
equal(
|
||||
groupToolActivities(calls, false).map((group) => group.state),
|
||||
['finished', 'finished'],
|
||||
'completion removes all active states',
|
||||
);
|
||||
equal(
|
||||
groupToolActivities(calls, false, true).map((group) => group.state),
|
||||
['finished', 'stopped'],
|
||||
'cancel or error cannot claim final step completed',
|
||||
);
|
||||
equal(
|
||||
groupToolActivities(
|
||||
[
|
||||
{ name: 'Read', running: false },
|
||||
{ name: 'Bash', running: false },
|
||||
{ name: 'Read', running: true },
|
||||
],
|
||||
true,
|
||||
).length,
|
||||
3,
|
||||
'do not merge across different stages',
|
||||
);
|
||||
equal(groupToolActivities([], true), [], 'no invented activity before a real tool event');
|
||||
equal(calls[3].running, true, 'projection does not mutate replay state');
|
||||
console.log('lessComputerToolActivity.test.ts passed');
|
||||
@@ -0,0 +1,86 @@
|
||||
/** Presentation categories inferred only from actual tool names, never arguments or filenames. */
|
||||
export type ToolActivityCategory = 'search' | 'read' | 'command' | 'edit' | 'web' | 'other';
|
||||
export type ToolActivityState = 'active' | 'finished' | 'stopped';
|
||||
|
||||
export interface ToolActivityEvent {
|
||||
name: string;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface ToolActivityGroup {
|
||||
category: ToolActivityCategory;
|
||||
state: ToolActivityState;
|
||||
tools: ToolActivityEvent[];
|
||||
names: { name: string; count: number }[];
|
||||
}
|
||||
|
||||
const CATEGORIES: Record<string, ToolActivityCategory> = {
|
||||
grep: 'search',
|
||||
glob: 'search',
|
||||
search: 'search',
|
||||
websearch: 'search',
|
||||
searchquery: 'search',
|
||||
findfiles: 'search',
|
||||
searchfiles: 'search',
|
||||
listdirectory: 'search',
|
||||
listfiles: 'search',
|
||||
ls: 'search',
|
||||
read: 'read',
|
||||
readfile: 'read',
|
||||
readtextfile: 'read',
|
||||
readmultiplefiles: 'read',
|
||||
cat: 'read',
|
||||
bash: 'command',
|
||||
shell: 'command',
|
||||
terminal: 'command',
|
||||
execcommand: 'command',
|
||||
runcommand: 'command',
|
||||
executeshell: 'command',
|
||||
run: 'command',
|
||||
edit: 'edit',
|
||||
write: 'edit',
|
||||
writefile: 'edit',
|
||||
applypatch: 'edit',
|
||||
multiedit: 'edit',
|
||||
strreplace: 'edit',
|
||||
strreplaceeditor: 'edit',
|
||||
webfetch: 'web',
|
||||
fetch: 'web',
|
||||
browse: 'web',
|
||||
openurl: 'web',
|
||||
browsernavigate: 'web',
|
||||
};
|
||||
|
||||
export function toolActivityCategory(name: string): ToolActivityCategory {
|
||||
const parts = name.trim().split(/__|[.:/]/);
|
||||
const operation = (parts[parts.length - 1] ?? '').replace(/[\s_-]/g, '').toLowerCase();
|
||||
return CATEGORIES[operation] ?? 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Consecutive calls in one category form a display step. "finished" means the
|
||||
* activity stream advanced, not that a tool returned success; no result payload
|
||||
* exists in this protocol. Terminal errors/cancellation stop the trailing step.
|
||||
*/
|
||||
export function groupToolActivities(
|
||||
tools: readonly ToolActivityEvent[],
|
||||
working: boolean,
|
||||
interrupted = false,
|
||||
): ToolActivityGroup[] {
|
||||
const groups: ToolActivityGroup[] = [];
|
||||
for (const tool of tools) {
|
||||
const category = toolActivityCategory(tool.name);
|
||||
let group = groups[groups.length - 1];
|
||||
if (!group || group.category !== category) {
|
||||
group = { category, state: 'finished', tools: [], names: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.tools.push(tool);
|
||||
const previous = group.names[group.names.length - 1];
|
||||
if (previous?.name === tool.name) previous.count += 1;
|
||||
else group.names.push({ name: tool.name, count: 1 });
|
||||
if (working && tool.running) group.state = 'active';
|
||||
}
|
||||
if (!working && interrupted && groups.length > 0) groups[groups.length - 1].state = 'stopped';
|
||||
return groups;
|
||||
}
|
||||
+68
-14
@@ -1,12 +1,17 @@
|
||||
// @ts-nocheck — Node-only handler replay; production UI remains strictly typed.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
const app = fileURLToPath(new URL('../', import.meta.url));
|
||||
const app = process.argv[2] || process.cwd();
|
||||
const require = createRequire(resolve(app, 'package.json'));
|
||||
const ts = require('typescript');
|
||||
const replayModule = await import(pathToFileURL(resolve(app, 'src/lib/lessComputerReplay.ts')));
|
||||
const replayModule = await import(
|
||||
new URL('file://' + resolve(app, 'src/lib/lessComputerReplay.ts'))
|
||||
);
|
||||
const activityModule = await import(
|
||||
new URL('file://' + resolve(app, 'src/lib/lessComputerToolActivity.ts'))
|
||||
);
|
||||
const source = readFileSync(resolve(app, 'src/pages/LessComputerPanel.tsx'), 'utf8');
|
||||
const parsed = ts.createSourceFile(
|
||||
'LessComputerPanel.tsx',
|
||||
@@ -44,7 +49,7 @@ const factory = new Function(
|
||||
'__getWindow',
|
||||
'exports',
|
||||
'require',
|
||||
`${compiled};return {LessComputerPanel,Composer,ApprovalCard,TurnView,ActivityPanel,voiceTime};`,
|
||||
`${compiled};return {LessComputerPanel,Composer,ApprovalCard,TurnView,ToolProcess,voiceTime};`,
|
||||
);
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const deferred = () => {
|
||||
@@ -152,6 +157,7 @@ function context(native, replay) {
|
||||
},
|
||||
chatPanelFocusKeyboard: async () => {},
|
||||
...replayModule,
|
||||
...activityModule,
|
||||
});
|
||||
const api = factory(
|
||||
...names.map((n) => imports[n]),
|
||||
@@ -493,22 +499,70 @@ await test('native window actions use hide/minimize/toggleMaximize; Escape close
|
||||
assert.equal(c.calls.windows.length, 3);
|
||||
c.hooks.unmount();
|
||||
});
|
||||
await test('activity derives tool report/approval/cost only; unknown API values and text deltas create no invented events', async () => {
|
||||
await test('tool activity is collapsed, grouped and loses shimmer on every terminal state', async () => {
|
||||
const c = context(false);
|
||||
const tree = c.api.ActivityPanel({
|
||||
const tools = [
|
||||
{ kind: 'tool', name: 'Read', running: false },
|
||||
{ kind: 'tool', name: 'Bash', running: false },
|
||||
{ kind: 'tool', name: 'Bash', running: true },
|
||||
];
|
||||
const active = c.api.ToolProcess({ tools, working: true, interrupted: false, t: (k) => k });
|
||||
assert.equal(active.type, 'details');
|
||||
assert.equal(active.props.open, undefined, 'native disclosure starts closed');
|
||||
assert.equal(
|
||||
nodes(active).filter((n) => n.props.className?.startsWith('lc-process-step is-')).length,
|
||||
2,
|
||||
);
|
||||
assert(nodes(active).some((n) => n.props.children === 'lessComputer.activity.commandRunning'));
|
||||
assert(nodes(active).some((n) => n.props.className === 'lc-process-label is-running'));
|
||||
assert(nodes(active).some((n) => n.props.className === 'lc-process-phase is-running'));
|
||||
assert(nodes(active).some((n) => n.props.children === 'Read'));
|
||||
assert(nodes(active).some((n) => n.props.children === 'Bash'));
|
||||
for (const interrupted of [false, true]) {
|
||||
const ended = c.api.ToolProcess({ tools, working: false, interrupted, t: (k) => k });
|
||||
assert(!nodes(ended).some((n) => n.props.className?.includes('is-running')));
|
||||
assert.equal(
|
||||
nodes(ended).some((n) => n.props.className === 'lc-process-step is-stopped'),
|
||||
interrupted,
|
||||
);
|
||||
}
|
||||
});
|
||||
await test('approval stays directly visible between separate folded tool blocks', async () => {
|
||||
const c = context(false);
|
||||
const tree = c.api.TurnView({
|
||||
index: 0,
|
||||
actionable: true,
|
||||
onApproval() {},
|
||||
t: (k) => k,
|
||||
turn: {
|
||||
user: 'task',
|
||||
user: 'fixture',
|
||||
status: 'working',
|
||||
errorMsg: '',
|
||||
costUsd: null,
|
||||
segments: [
|
||||
{ kind: 'tool', name: 'Read', running: false },
|
||||
{ kind: 'text', content: 'answer' },
|
||||
{
|
||||
kind: 'approval',
|
||||
token: 'real-token',
|
||||
command: 'fixture command',
|
||||
reason: 'fixture reason',
|
||||
},
|
||||
{ kind: 'tool', name: 'Bash', running: true },
|
||||
],
|
||||
status: 'done',
|
||||
costUsd: null,
|
||||
},
|
||||
t: (k) => k,
|
||||
});
|
||||
assert.equal(nodes(tree).filter((n) => n.type === 'li').length, 1);
|
||||
assert(nodes(tree).some((n) => n.props.children === 'lessComputer.desktop.toolReported'));
|
||||
assert(!nodes(tree).some((n) => n.props.className === 'lc-api-cost'));
|
||||
assert.equal(nodes(tree).filter((n) => n.type === c.api.ToolProcess).length, 2);
|
||||
assert.equal(nodes(tree).filter((n) => n.type === c.api.ApprovalCard).length, 1);
|
||||
});
|
||||
await test('window controls are first, agents are text only and activity is not duplicated', async () => {
|
||||
const c = context(false);
|
||||
const tree = c.render();
|
||||
const shell = find(tree, (n) => n.props.className === 'lc-desktop');
|
||||
const first = shell.props.children.find(Boolean);
|
||||
assert.equal(first.type, 'header');
|
||||
assert.equal(first.props.children[0].props.className, 'lc-window-controls');
|
||||
assert(!nodes(tree).some((n) => n.type === 'AgentBuddy'));
|
||||
assert(!nodes(tree).some((n) => n.props.className === 'lc-activity'));
|
||||
c.hooks.unmount();
|
||||
});
|
||||
console.log(`${passed} actual-component behavior tests passed`);
|
||||
@@ -3,11 +3,9 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIcon,
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
CircleDotIcon,
|
||||
HistoryIcon,
|
||||
LayersIcon,
|
||||
Maximize2Icon,
|
||||
@@ -15,7 +13,6 @@ import {
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
TerminalIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -26,7 +23,6 @@ import {
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from '../components/chat/ui/message-scroller';
|
||||
import { AgentBuddy, type BuddyColor } from '../components/chat/AgentBuddy';
|
||||
import { VoiceWaveform } from '../components/chat/VoiceWaveform';
|
||||
import { AssistantMarkdown } from '../components/chat/markdown';
|
||||
import { useChatPanelLifecycle } from '../components/chat/lifecycle';
|
||||
@@ -48,14 +44,15 @@ import type {
|
||||
LessComputerVoiceEvent,
|
||||
UserPreferences,
|
||||
} from '../lib/types';
|
||||
import { groupToolActivities, toolActivityCategory } from '../lib/lessComputerToolActivity';
|
||||
import './less-computer-panel.css';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
const AGENTS: { id: CodingAgentProviderId; name: string; color: BuddyColor }[] = [
|
||||
{ id: 'claude-code-cli', name: 'Claude Code', color: 'coral' },
|
||||
{ id: 'opencode-cli', name: 'OpenCode', color: 'amber' },
|
||||
{ id: 'codex-cli', name: 'Codex', color: 'violet' },
|
||||
{ id: 'dsh-cli', name: 'dsh', color: 'mint' },
|
||||
const AGENTS: { id: CodingAgentProviderId; name: string }[] = [
|
||||
{ id: 'claude-code-cli', name: 'Claude Code' },
|
||||
{ id: 'opencode-cli', name: 'OpenCode' },
|
||||
{ id: 'codex-cli', name: 'Codex' },
|
||||
{ id: 'dsh-cli', name: 'dsh' },
|
||||
];
|
||||
|
||||
type RunStatus = 'idle' | 'working' | 'done' | 'error' | 'cancelled';
|
||||
@@ -425,19 +422,45 @@ export function LessComputerPanel() {
|
||||
className={`lc-desktop${closing ? ' is-closing' : ''}`}
|
||||
key={`${sessionSeq}-${enterEpoch}`}
|
||||
>
|
||||
<aside className="lc-sidebar" aria-label={t('lessComputer.desktop.agents')}>
|
||||
<div className="lc-brand" data-tauri-drag-region>
|
||||
<span className="lc-brand-mark" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<span data-tauri-drag-region>
|
||||
Less
|
||||
<br />
|
||||
<strong>Computer</strong>
|
||||
</span>
|
||||
<header className="lc-topbar" data-tauri-drag-region>
|
||||
<div className="lc-window-controls">
|
||||
<button
|
||||
className="lc-window-close"
|
||||
type="button"
|
||||
disabled={!isTauri}
|
||||
aria-label={t('lessComputer.closeTooltip')}
|
||||
title={t('lessComputer.closeTooltip')}
|
||||
onClick={() => void windowAction('hide')}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
<button
|
||||
className="lc-window-minimize"
|
||||
type="button"
|
||||
disabled={!isTauri}
|
||||
aria-label={t('lessComputer.desktop.minimize')}
|
||||
title={t('lessComputer.desktop.minimize')}
|
||||
onClick={() => void windowAction('minimize')}
|
||||
>
|
||||
<MinusIcon />
|
||||
</button>
|
||||
<button
|
||||
className="lc-window-maximize"
|
||||
type="button"
|
||||
disabled={!isTauri}
|
||||
aria-label={t('lessComputer.desktop.maximize')}
|
||||
title={t('lessComputer.desktop.maximize')}
|
||||
onClick={() => void windowAction('maximize')}
|
||||
>
|
||||
<Maximize2Icon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="lc-session-title" data-tauri-drag-region>
|
||||
<span className="lc-session-dot" />
|
||||
<span data-tauri-drag-region>{t('lessComputer.desktop.currentSession')}</span>
|
||||
</div>
|
||||
</header>
|
||||
<aside className="lc-sidebar" aria-label={t('lessComputer.desktop.agents')}>
|
||||
<div className="lc-sidebar-scroll">
|
||||
<div className="lc-section-label">{t('lessComputer.desktop.agents')}</div>
|
||||
<div className="lc-agent-list">
|
||||
@@ -447,7 +470,6 @@ export function LessComputerPanel() {
|
||||
className={`lc-agent${provider === agent.id ? ' is-configured' : ''}`}
|
||||
title={agent.name}
|
||||
>
|
||||
<AgentBuddy color={agent.color} size={42} />
|
||||
<div className="lc-agent-name">
|
||||
<strong>{agent.name}</strong>
|
||||
<span>
|
||||
@@ -510,45 +532,6 @@ export function LessComputerPanel() {
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<header className="lc-topbar" data-tauri-drag-region>
|
||||
<div className="lc-session-title" data-tauri-drag-region>
|
||||
<span className="lc-session-dot" />
|
||||
<span data-tauri-drag-region>{t('lessComputer.desktop.currentSession')}</span>
|
||||
</div>
|
||||
<div className="lc-window-controls">
|
||||
<button
|
||||
className="lc-window-close"
|
||||
type="button"
|
||||
disabled={!isTauri}
|
||||
aria-label={t('lessComputer.closeTooltip')}
|
||||
title={t('lessComputer.closeTooltip')}
|
||||
onClick={() => void windowAction('hide')}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
<button
|
||||
className="lc-window-minimize"
|
||||
type="button"
|
||||
disabled={!isTauri}
|
||||
aria-label={t('lessComputer.desktop.minimize')}
|
||||
title={t('lessComputer.desktop.minimize')}
|
||||
onClick={() => void windowAction('minimize')}
|
||||
>
|
||||
<MinusIcon />
|
||||
</button>
|
||||
<button
|
||||
className="lc-window-maximize"
|
||||
type="button"
|
||||
disabled={!isTauri}
|
||||
aria-label={t('lessComputer.desktop.maximize')}
|
||||
title={t('lessComputer.desktop.maximize')}
|
||||
onClick={() => void windowAction('maximize')}
|
||||
>
|
||||
<Maximize2Icon />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="lc-conversation" aria-label={t('lessComputer.desktop.currentSession')}>
|
||||
{windowError && (
|
||||
<p className="lc-notice" role="alert">
|
||||
@@ -570,11 +553,6 @@ export function LessComputerPanel() {
|
||||
>
|
||||
{turns.length === 0 ? (
|
||||
<div className="lc-empty">
|
||||
<div className="lc-empty-buddies" aria-hidden="true">
|
||||
{AGENTS.map((agent) => (
|
||||
<AgentBuddy key={agent.id} color={agent.color} size={64} />
|
||||
))}
|
||||
</div>
|
||||
<h2>{t('lessComputer.subtitle')}</h2>
|
||||
<p>{t('lessComputer.desktop.emptyHint')}</p>
|
||||
<span className="lc-empty-label">
|
||||
@@ -611,7 +589,6 @@ export function LessComputerPanel() {
|
||||
</div>
|
||||
<Composer working={working} voice={voice} t={t} />
|
||||
</main>
|
||||
<ActivityPanel turn={latestTurn} t={t} />
|
||||
</div>
|
||||
{loginOpen && isTauri && (
|
||||
<GithubLoginModal
|
||||
@@ -633,7 +610,12 @@ function runStatusLabel(turn: Turn | undefined, t: Translate): string {
|
||||
turn.segments.some((segment) => segment.kind === 'approval' && !segment.decision)
|
||||
)
|
||||
return t('lessComputer.desktop.waitingApproval');
|
||||
if (turn.status === 'working') return t('lessComputer.working');
|
||||
if (turn.status === 'working') {
|
||||
const active = turn.segments.find((segment) => segment.kind === 'tool' && segment.running);
|
||||
return active?.kind === 'tool'
|
||||
? t(`lessComputer.activity.${toolActivityCategory(active.name)}Running`)
|
||||
: t('lessComputer.working');
|
||||
}
|
||||
if (turn.status === 'done') return t('lessComputer.done');
|
||||
if (turn.status === 'cancelled') return t('common.cancelled');
|
||||
if (turn.status === 'error') return t('lessComputer.error');
|
||||
@@ -797,12 +779,7 @@ function TurnView({
|
||||
)}
|
||||
<MessageScrollerItem messageId={`t${index}-assistant`} scrollAnchor={!hasUser}>
|
||||
<div className="lc-assistant-message">
|
||||
<div className="lc-assistant-label">
|
||||
<span className="lc-assistant-symbol" aria-hidden="true">
|
||||
✳
|
||||
</span>
|
||||
Less Computer
|
||||
</div>
|
||||
<div className="lc-assistant-label">Less Computer</div>
|
||||
{turn.segments.map((segment, i) => {
|
||||
if (segment.kind === 'text')
|
||||
return (
|
||||
@@ -813,18 +790,29 @@ function TurnView({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (segment.kind === 'tool')
|
||||
if (segment.kind === 'tool') {
|
||||
if (turn.segments[i - 1]?.kind === 'tool') return null;
|
||||
const tools: ToolSegment[] = [];
|
||||
let end = i;
|
||||
while (end < turn.segments.length) {
|
||||
const candidate = turn.segments[end];
|
||||
if (candidate.kind !== 'tool') break;
|
||||
tools.push(candidate);
|
||||
end += 1;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`lc-tool-event${segment.running ? ' is-active' : ''}`}
|
||||
<ToolProcess
|
||||
key={`s${i}`}
|
||||
role={segment.running ? 'status' : undefined}
|
||||
>
|
||||
<TerminalIcon />
|
||||
<span>{t('lessComputer.tool', { name: segment.name })}</span>
|
||||
{segment.running && <span className="lc-active-dot" />}
|
||||
</div>
|
||||
tools={tools}
|
||||
working={turn.status === 'working'}
|
||||
interrupted={
|
||||
(turn.status === 'error' || turn.status === 'cancelled') &&
|
||||
end === turn.segments.length
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (segment.kind === 'compaction')
|
||||
return (
|
||||
<div className="lc-compaction" key={`s${i}`}>
|
||||
@@ -871,6 +859,82 @@ function TurnView({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolProcess({
|
||||
tools,
|
||||
working,
|
||||
interrupted,
|
||||
t,
|
||||
}: {
|
||||
tools: ToolSegment[];
|
||||
working: boolean;
|
||||
interrupted: boolean;
|
||||
t: Translate;
|
||||
}) {
|
||||
const groups = groupToolActivities(tools, working, interrupted);
|
||||
const active = groups.find((group) => group.state === 'active');
|
||||
return (
|
||||
<details className="lc-tool-process">
|
||||
<summary>
|
||||
<ChevronRightIcon className="lc-process-chevron" />
|
||||
<span className={`lc-process-label${active ? ' is-running' : ''}`}>
|
||||
{active
|
||||
? t(`lessComputer.activity.${active.category}Running`)
|
||||
: t('lessComputer.activity.process')}
|
||||
</span>
|
||||
<span className="lc-process-count">
|
||||
{t('lessComputer.activity.count', { count: tools.length })}
|
||||
</span>
|
||||
{!active && (
|
||||
<span className="lc-process-result">
|
||||
{interrupted ? t('lessComputer.activity.stopped') : t('lessComputer.activity.finished')}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
<ol className="lc-process-steps">
|
||||
{groups.map((group, index) => (
|
||||
<li key={index} className={`lc-process-step is-${group.state}`}>
|
||||
<span className="lc-process-marker" aria-hidden="true">
|
||||
{group.state === 'active' ? (
|
||||
<span />
|
||||
) : group.state === 'stopped' ? (
|
||||
<MinusIcon />
|
||||
) : (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</span>
|
||||
<div className="lc-process-step-content">
|
||||
<div className="lc-process-phase-row">
|
||||
<span
|
||||
className={`lc-process-phase${group.state === 'active' ? ' is-running' : ''}`}
|
||||
>
|
||||
{t(
|
||||
`lessComputer.activity.${group.category}${group.state === 'active' ? 'Running' : ''}`,
|
||||
)}
|
||||
</span>
|
||||
{group.state !== 'active' && (
|
||||
<span className="lc-process-result">
|
||||
{t(
|
||||
`lessComputer.activity.${group.state === 'stopped' ? 'stopped' : 'finished'}`,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="lc-process-tools">
|
||||
{group.names.map((tool, toolIndex) => (
|
||||
<li key={toolIndex}>
|
||||
<span>{tool.name}</span>
|
||||
{tool.count > 1 && <span className="lc-tool-count">×{tool.count}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalCard({
|
||||
card,
|
||||
actionable,
|
||||
@@ -931,82 +995,6 @@ function ApprovalCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityPanel({ turn, t }: { turn: Turn | undefined; t: Translate }) {
|
||||
const events = turn?.segments.filter((segment) => segment.kind !== 'text') ?? [];
|
||||
return (
|
||||
<aside className="lc-activity" aria-label={t('lessComputer.desktop.activity')}>
|
||||
<div className="lc-activity-heading">
|
||||
<ActivityIcon />
|
||||
<h2>{t('lessComputer.desktop.activity')}</h2>
|
||||
</div>
|
||||
{!turn ? (
|
||||
<div className="lc-activity-empty">
|
||||
<CircleDotIcon />
|
||||
<strong>{t('lessComputer.desktop.noActivity')}</strong>
|
||||
<p>{t('lessComputer.desktop.activityHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="lc-task-summary">
|
||||
<span className="lc-section-label">{t('lessComputer.desktop.currentTask')}</span>
|
||||
{turn.user && <p>{turn.user}</p>}
|
||||
<strong>{runStatusLabel(turn, t)}</strong>
|
||||
</div>
|
||||
<ol className="lc-activity-list">
|
||||
{events.map((event, index) => (
|
||||
<li key={index}>
|
||||
<span className="lc-activity-node">
|
||||
{event.kind === 'tool' ? (
|
||||
<TerminalIcon />
|
||||
) : event.kind === 'approval' ? (
|
||||
<ShieldCheckIcon />
|
||||
) : (
|
||||
<LayersIcon />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
{event.kind === 'tool' ? (
|
||||
<>
|
||||
<strong>{event.name}</strong>
|
||||
<span>
|
||||
{event.running
|
||||
? t('lessComputer.desktop.toolActive')
|
||||
: t('lessComputer.desktop.toolReported')}
|
||||
</span>
|
||||
</>
|
||||
) : event.kind === 'compaction' ? (
|
||||
<strong>{t('lessComputer.compaction')}</strong>
|
||||
) : (
|
||||
<>
|
||||
<strong>{t('lessComputer.desktop.approval')}</strong>
|
||||
<span>
|
||||
{event.decision
|
||||
? t(`lessComputer.desktop.${event.decision}Submitted`)
|
||||
: event.pending
|
||||
? t('lessComputer.desktop.submittingApproval')
|
||||
: turn.status === 'working'
|
||||
? t('lessComputer.desktop.waitingApproval')
|
||||
: t('lessComputer.desktop.approvalExpired')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{turn.costUsd != null && (
|
||||
<div className="lc-api-cost">
|
||||
<span>{t('lessComputer.desktop.apiCost')}</span>
|
||||
<strong>{t('lessComputer.cost', { cost: turn.costUsd.toFixed(3) })}</strong>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p className="lc-activity-source">{t('lessComputer.desktop.activitySource')}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function GithubMark() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -102,7 +102,7 @@ const factory = new Function(
|
||||
'exports',
|
||||
'require',
|
||||
'__listen',
|
||||
`${compiled};return {CloudSyncSection,PasswordForm,ConsentForm,RestoreReview};`,
|
||||
`${compiled};return {CloudSyncSection,PasswordForm,ConsentForm,ProtocolWarning,RestoreReview};`,
|
||||
);
|
||||
|
||||
const initial: EncryptedSyncStatus = {
|
||||
@@ -275,6 +275,22 @@ try {
|
||||
assert.equal(find(consent, (node) => node.type === 'input').props.checked, false);
|
||||
assert.equal(find(consent, (node) => node.type === 'button').props.disabled, true);
|
||||
|
||||
const protocolHooks = new Hooks();
|
||||
const protocolProps = { busy: false, onConfirm() {}, onBack() {} };
|
||||
let protocolTree = protocolHooks.render(() => components.ProtocolWarning(protocolProps));
|
||||
const confirmProtocol = () =>
|
||||
find(protocolTree, (node) => node.props.children === 'cloudSyncE2ee.protocolConfirm');
|
||||
assert.equal(confirmProtocol().props.disabled, true);
|
||||
find(protocolTree, (node) => node.type === 'input').props.onChange({
|
||||
currentTarget: { checked: true },
|
||||
});
|
||||
protocolTree = protocolHooks.render(() => components.ProtocolWarning(protocolProps));
|
||||
assert.equal(confirmProtocol().props.disabled, false);
|
||||
protocolTree = protocolHooks.render(() =>
|
||||
components.ProtocolWarning({ ...protocolProps, busy: true }),
|
||||
);
|
||||
assert.equal(confirmProtocol().props.disabled, true);
|
||||
|
||||
const hooks = new Hooks();
|
||||
const render = () => hooks.render(() => components.CloudSyncSection());
|
||||
render();
|
||||
@@ -289,6 +305,19 @@ try {
|
||||
find(tree, (node) => node.type === components.ConsentForm).props.onConfirm();
|
||||
await settle();
|
||||
tree = render();
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
[],
|
||||
'scope consent alone must not prepare or upload before the privacy warning',
|
||||
);
|
||||
find(tree, (node) => node.type === components.ProtocolWarning).props.onBack();
|
||||
tree = render();
|
||||
assert.deepEqual(calls, [], 'returning from the warning must not perform native sync work');
|
||||
find(tree, (node) => node.type === components.ConsentForm).props.onConfirm();
|
||||
tree = render();
|
||||
find(tree, (node) => node.type === components.ProtocolWarning).props.onConfirm();
|
||||
await settle();
|
||||
tree = render();
|
||||
assert.deepEqual(
|
||||
calls.slice(0, 2).map(([name]) => name),
|
||||
['mirror', 'prepare'],
|
||||
@@ -419,6 +448,10 @@ try {
|
||||
find(tree, (node) => node.type === components.ConsentForm).props.onConfirm();
|
||||
await settle();
|
||||
tree = fresh();
|
||||
assert.deepEqual(calls, [], 'a new vault also requires the second privacy acknowledgement');
|
||||
find(tree, (node) => node.type === components.ProtocolWarning).props.onConfirm();
|
||||
await settle();
|
||||
tree = fresh();
|
||||
const createForm = find(tree, (node) => node.type === components.PasswordForm);
|
||||
assert.equal(createForm.props.mode, 'create');
|
||||
createForm.props.onSubmit({
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type Intent = 'enable' | 'unlock' | 'restore';
|
||||
type Dialog =
|
||||
| { kind: 'consent'; intent: Intent }
|
||||
| { kind: 'protocol'; intent: Intent }
|
||||
| { kind: 'create'; observedRevision: string }
|
||||
| { kind: 'unlock'; intent: Intent }
|
||||
| { kind: 'password' }
|
||||
@@ -190,9 +191,11 @@ export function CloudSyncSection() {
|
||||
alive.current = true;
|
||||
let disposed = false;
|
||||
const unlisten: Array<() => void> = [];
|
||||
const uiPersistenceFailed = () => {
|
||||
const uiPersistenceFailed = (event: Event) => {
|
||||
if (disposed) return;
|
||||
showError(localError('local_storage_unavailable'));
|
||||
// Render only allow-listed stable codes; preserve a denied-vault or busy
|
||||
// cause instead of replacing every failure with a generic storage error.
|
||||
showError((event as CustomEvent<unknown>).detail ?? localError('local_storage_unavailable'));
|
||||
void loadRef.current();
|
||||
};
|
||||
window.addEventListener('openless:sync-ui-persistence-failed', uiPersistenceFailed);
|
||||
@@ -309,6 +312,7 @@ export function CloudSyncSection() {
|
||||
}
|
||||
};
|
||||
const begin = (intent: Intent) => {
|
||||
setNotice(null);
|
||||
if (intent === 'enable' || current.current?.consentVersion !== CONSENT_VERSION) {
|
||||
setDialog({ kind: 'consent', intent });
|
||||
} else {
|
||||
@@ -628,7 +632,12 @@ export function CloudSyncSection() {
|
||||
/>
|
||||
)}
|
||||
{dialog && (
|
||||
<SyncDialog title={t(`cloudSyncE2ee.${dialogTitle}`)} busy={working} onClose={closeDialog}>
|
||||
<SyncDialog
|
||||
key={dialog.kind}
|
||||
title={t(`cloudSyncE2ee.${dialogTitle}`)}
|
||||
busy={working}
|
||||
onClose={closeDialog}
|
||||
>
|
||||
{notice?.error && (
|
||||
<p role="alert" style={{ color: 'var(--ol-err)' }}>
|
||||
{t(`cloudSyncE2ee.${notice.key}`)}
|
||||
@@ -637,6 +646,19 @@ export function CloudSyncSection() {
|
||||
{dialog.kind === 'consent' && (
|
||||
<ConsentForm
|
||||
busy={working}
|
||||
onConfirm={() => {
|
||||
setNotice(null);
|
||||
setDialog({ kind: 'protocol', intent: dialog.intent });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dialog.kind === 'protocol' && (
|
||||
<ProtocolWarning
|
||||
busy={working}
|
||||
onBack={() => {
|
||||
setNotice(null);
|
||||
setDialog({ kind: 'consent', intent: dialog.intent });
|
||||
}}
|
||||
onConfirm={() =>
|
||||
void perform(async (valid) =>
|
||||
routePreparation(await prepareEncryptedSync(), dialog.intent, valid),
|
||||
@@ -736,6 +758,64 @@ function ConsentForm({ busy, onConfirm }: { busy: boolean; onConfirm: () => void
|
||||
);
|
||||
}
|
||||
|
||||
function ProtocolWarning({
|
||||
busy,
|
||||
onConfirm,
|
||||
onBack,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onConfirm: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 18 }}>
|
||||
<p style={{ margin: 0, color: 'var(--ol-ink-3)', lineHeight: 1.65 }}>
|
||||
{t('cloudSyncE2ee.protocolIntro')}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
padding: 18,
|
||||
border: '1px solid var(--ol-line)',
|
||||
borderRadius: 14,
|
||||
background: 'var(--ol-surface-2)',
|
||||
}}
|
||||
>
|
||||
{['Password', 'Encryption', 'Excluded'].map((section) => (
|
||||
<section key={section}>
|
||||
<h4 style={{ fontSize: 14, margin: '0 0 6px', color: 'var(--ol-ink)' }}>
|
||||
{t(`cloudSyncE2ee.protocol${section}Title`)}
|
||||
</h4>
|
||||
<p style={{ margin: 0, lineHeight: 1.7, color: 'var(--ol-ink-3)' }}>
|
||||
{t(`cloudSyncE2ee.protocol${section}`)}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 9, lineHeight: 1.6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
disabled={busy}
|
||||
onChange={(event) => setAcknowledged(event.currentTarget.checked)}
|
||||
/>
|
||||
{t('cloudSyncE2ee.protocolCheck')}
|
||||
</label>
|
||||
<div className="ol-cloud-sync-actions" style={{ justifyContent: 'flex-end' }}>
|
||||
<Btn disabled={busy} onClick={onBack}>
|
||||
{t('cloudSyncE2ee.protocolBack')}
|
||||
</Btn>
|
||||
<Btn variant="primary" disabled={!acknowledged || busy} onClick={onConfirm}>
|
||||
{t('cloudSyncE2ee.protocolConfirm')}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordForm({
|
||||
mode,
|
||||
busy,
|
||||
@@ -895,7 +975,9 @@ function RestoreReview({
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<p>{t('cloudSyncE2ee.restoreDescription')}</p>
|
||||
{preview.unconfirmedOperationId && <p role="note">{t('cloudSyncE2ee.errors.outcomeUnknown')}</p>}
|
||||
{preview.unconfirmedOperationId && (
|
||||
<p role="note">{t('cloudSyncE2ee.errors.outcomeUnknown')}</p>
|
||||
)}
|
||||
<ul style={{ margin: 0, paddingLeft: 20, lineHeight: 1.75 }}>
|
||||
{Object.entries(preview.counts)
|
||||
.filter(([, count]) => count > 0)
|
||||
|
||||
Reference in new issue
Block a user