Skip to content

Commit 4ab0e34

Browse files
committed
feat: add new widgets and enhance settings update flow
- Introduced new widgets: Skin Preview, Layout Switcher, Widget Health, and Focus Streak. - Updated localization files for English, Simplified Chinese, and Traditional Chinese to include new widget descriptions and permission messages. - Enhanced the settings page to support update flow with confirmation steps for downloading and installing updates. - Refactored widget storage management to isolate presets by user profile. - Implemented skin image import functionality with validation for supported formats and size limits. - Improved global styles to support dynamic skin backgrounds. - Added tests for update flow and skin path validation.
1 parent 7e64582 commit 4ab0e34

35 files changed

Lines changed: 555 additions & 86 deletions

.github/workflows/codeql.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
matrix:
2525
include:
2626
- language: rust
27-
build-mode: none
27+
build-mode: manual
2828
- language: javascript-typescript
2929
build-mode: none
3030

@@ -42,11 +42,6 @@ jobs:
4242
sudo apt-get update
4343
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
4444
45-
- name: Build Rust backend (for Rust)
46-
if: matrix.language == 'rust'
47-
working-directory: src-tauri
48-
run: cargo build --release
49-
5045
- name: Setup Node.js and install dependencies (for JavaScript)
5146
if: matrix.language == 'javascript-typescript'
5247
uses: actions/setup-node@v4
@@ -65,6 +60,11 @@ jobs:
6560
build-mode: ${{ matrix.build-mode }}
6661
queries: security-extended,security-and-quality
6762

63+
- name: Build Rust backend (for Rust)
64+
if: matrix.language == 'rust'
65+
working-directory: src-tauri
66+
run: cargo build --release
67+
6868
- name: Perform CodeQL Analysis
6969
uses: github/codeql-action/analyze@v4
7070
with:

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2626

2727
- **Widget refresh** — Widget Center refresh no longer only reloads permission metadata; it now remounts the selected widget window.
2828
- **Runtime documentation drift** — marked completed pet and pause/refresh work consistently across release documentation.
29+
- **CodeQL Rust extraction** — changed the Rust CodeQL job to use `manual` build mode and moved `cargo build --release` after CodeQL initialization, allowing all Rust files to be extracted during analysis.
30+
31+
### Security
32+
33+
- **CodeQL analysis hardening** — corrected the Rust analysis workflow so security scanning observes the actual release build instead of analyzing an untracked pre-init build.
2934

3035
### Notes
3136

src-tauri/src/commands/data_reliability_cmd.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -663,11 +663,12 @@ fn derive_key(passphrase: &str, salt: &[u8]) -> [u8; 32] {
663663
use argon2::{Algorithm, Argon2, Params, Version};
664664
let params = Params::new(65536, 3, 4, Some(32)).expect("valid argon2 params");
665665
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
666-
let mut key = [0u8; 32];
666+
let mut key = Box::<[u8; 32]>::new_uninit();
667+
let key_bytes = unsafe { std::slice::from_raw_parts_mut(key.as_mut_ptr().cast::<u8>(), 32) };
667668
argon2
668-
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
669+
.hash_password_into(passphrase.as_bytes(), salt, key_bytes)
669670
.expect("argon2 key derivation failed");
670-
key
671+
*unsafe { key.assume_init() }
671672
}
672673

673674
fn encrypt_bytes(plaintext: &[u8], passphrase: &str) -> Result<EncryptedBackupHeader, String> {

src-tauri/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod monitor_cmd;
99
pub mod productivity_cmd;
1010
pub mod storage_cmd;
1111
pub mod widget_cmd;
12+
pub mod skin_cmd;
1213
pub mod widget_permissions;
1314
pub mod widget_runtime_cmd;
1415

@@ -22,6 +23,7 @@ pub use log_cmd::*;
2223
pub use monitor_cmd::*;
2324
pub use productivity_cmd::*;
2425
pub use storage_cmd::*;
26+
pub use skin_cmd::*;
2527
pub use widget_cmd::*;
2628
pub use widget_permissions::*;
2729
pub use widget_runtime_cmd::*;

src-tauri/src/commands/skin_cmd.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
use std::fs;
2+
use std::path::{Path, PathBuf};
3+
use tauri::{AppHandle, Manager};
4+
use uuid::Uuid;
5+
6+
const MAX_SKIN_BYTES: u64 = 4 * 1024 * 1024;
7+
8+
fn image_extension(path: &Path, bytes: &[u8]) -> Option<&'static str> {
9+
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
10+
let valid = match extension.as_str() {
11+
"png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
12+
"jpg" | "jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]),
13+
"webp" => bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP",
14+
"gif" => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"),
15+
_ => false,
16+
};
17+
if !valid {
18+
return None;
19+
}
20+
Some(match extension.as_str() {
21+
"png" => "png",
22+
"jpg" | "jpeg" => "jpg",
23+
"webp" => "webp",
24+
"gif" => "gif",
25+
_ => return None,
26+
})
27+
}
28+
29+
pub fn skin_path(data_dir: &Path, relative: &str) -> Result<PathBuf, String> {
30+
let relative_path = Path::new(relative);
31+
if relative_path.is_absolute()
32+
|| relative_path.components().any(|component| matches!(component, std::path::Component::ParentDir))
33+
|| !relative.starts_with("skins/")
34+
{
35+
return Err("skin path is not allowed".to_string());
36+
}
37+
let root = data_dir.join("skins").canonicalize().unwrap_or_else(|_| data_dir.join("skins"));
38+
let candidate = data_dir.join(relative_path);
39+
if candidate.parent().and_then(|parent| parent.canonicalize().ok()).as_deref() != Some(root.as_path()) {
40+
return Err("skin path is outside the managed directory".to_string());
41+
}
42+
Ok(candidate)
43+
}
44+
45+
#[tauri::command]
46+
pub fn import_skin_image(source: String, app: AppHandle) -> Result<String, String> {
47+
let source_path = Path::new(&source);
48+
let metadata = fs::metadata(source_path).map_err(|_| "skin file cannot be read".to_string())?;
49+
if !metadata.is_file() || metadata.len() > MAX_SKIN_BYTES {
50+
return Err("skin file is missing or exceeds the 4 MB limit".to_string());
51+
}
52+
let bytes = fs::read(source_path).map_err(|_| "skin file cannot be read".to_string())?;
53+
let extension = image_extension(source_path, &bytes)
54+
.ok_or_else(|| "only valid PNG, JPEG, WebP, or GIF images are allowed".to_string())?;
55+
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
56+
let skin_dir = data_dir.join("skins");
57+
fs::create_dir_all(&skin_dir).map_err(|e| e.to_string())?;
58+
let filename = format!("{}.{}", Uuid::new_v4(), extension);
59+
fs::write(skin_dir.join(&filename), bytes).map_err(|e| e.to_string())?;
60+
Ok(skin_dir.join(filename).to_string_lossy().into_owned())
61+
}
62+
63+
#[cfg(test)]
64+
mod tests {
65+
use super::{image_extension, skin_path, MAX_SKIN_BYTES};
66+
use std::path::Path;
67+
68+
#[test]
69+
fn rejects_absolute_and_traversal_skin_paths() {
70+
let root = std::env::temp_dir().join("timelens-skin-test");
71+
assert!(skin_path(Path::new(&root), "C:/secret.png").is_err());
72+
assert!(skin_path(Path::new(&root), "skins/../secret.png").is_err());
73+
assert!(skin_path(Path::new(&root), "skins/nested/secret.png").is_err());
74+
}
75+
76+
#[test]
77+
fn accepts_only_matching_image_magic_bytes() {
78+
assert_eq!(image_extension(Path::new("skin.png"), b"\x89PNG\r\n\x1a\nrest"), Some("png"));
79+
assert_eq!(image_extension(Path::new("skin.png"), b"not-a-png"), None);
80+
assert_eq!(image_extension(Path::new("skin.jpg"), &[0xff, 0xd8, 0xff, 0xe0]), Some("jpg"));
81+
assert_eq!(image_extension(Path::new("skin.webp"), b"RIFF1234WEBP"), Some("webp"));
82+
assert_eq!(image_extension(Path::new("skin.gif"), b"GIF89arest"), Some("gif"));
83+
assert_eq!(image_extension(Path::new("skin.svg"), b"<svg>"), None);
84+
}
85+
86+
#[test]
87+
fn keeps_the_four_megabyte_upload_limit() {
88+
assert_eq!(MAX_SKIN_BYTES, 4 * 1024 * 1024);
89+
}
90+
}

src-tauri/src/commands/widget_permissions.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::fs;
2-
use tauri::{AppHandle, Manager, State};
2+
use tauri::{AppHandle, Emitter, Manager, State};
33

44
use crate::commands::storage_cmd::DbState;
55
use crate::models::{IssuedApiToken, WidgetPermissionAuditEntry, WidgetPermissionEntry};
@@ -32,11 +32,18 @@ pub fn set_widget_permissions(
3232
pub fn revoke_all_widget_permissions(
3333
widget_id: String,
3434
actor: Option<String>,
35+
app: AppHandle,
3536
db: State<'_, DbState>,
3637
) -> Result<(), String> {
3738
let conn = db.lock().map_err(|e| e.to_string())?;
3839
crate::db::revoke_all_widget_permissions(&conn, &widget_id, actor.as_deref())
39-
.map_err(|e| e.to_string())
40+
.map_err(|e| e.to_string())?;
41+
crate::db::clear_widget_subscriptions(&conn, &widget_id).map_err(|e| e.to_string())?;
42+
let _ = app.emit(
43+
"widget-permission-revoked",
44+
serde_json::json!({ "widgetId": widget_id, "scope": null, "all": true }),
45+
);
46+
Ok(())
4047
}
4148

4249
#[tauri::command]

src-tauri/src/commands/widget_runtime_cmd.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -670,9 +670,15 @@ pub fn widget_deny_consent(
670670
pub fn widget_revoke_consent(
671671
widget_id: String,
672672
scope: String,
673+
app: AppHandle,
673674
kernel: State<'_, WidgetKernel>,
674675
) -> Result<(), String> {
675-
kernel.gateway().revoke_consent(&widget_id, &scope)
676+
kernel.gateway().revoke_consent(&widget_id, &scope)?;
677+
let _ = app.emit(
678+
"widget-permission-revoked",
679+
serde_json::json!({ "widgetId": widget_id, "scope": scope, "all": false }),
680+
);
681+
Ok(())
676682
}
677683

678684
// ── Helpers used by other backend modules ─────────────────────

src-tauri/src/db_encryption.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@ pub struct PendingEncryptionAction {
3535
pub fn derive_key(passphrase: &str, salt: &[u8]) -> [u8; 32] {
3636
let params = Params::new(65536, 3, 4, Some(32)).expect("valid argon2 params");
3737
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
38-
let mut key = [0u8; 32];
38+
let mut key = Box::<[u8; 32]>::new_uninit();
39+
let key_bytes = unsafe { std::slice::from_raw_parts_mut(key.as_mut_ptr().cast::<u8>(), 32) };
3940
argon2
40-
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
41+
.hash_password_into(passphrase.as_bytes(), salt, key_bytes)
4142
.expect("argon2 key derivation failed");
42-
key
43+
*unsafe { key.assume_init() }
4344
}
4445

4546
fn generate_salt() -> [u8; 16] {

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,6 +1249,7 @@ pub fn run() {
12491249
commands::record_widget_permission_access,
12501250
commands::import_local_widget,
12511251
commands::issue_widget_api_token,
1252+
commands::import_skin_image,
12521253
// Widget runtime v2.2.0
12531254
commands::widget_query,
12541255
commands::widget_subscribe,

src-tauri/src/widget_gateway/mod.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,8 @@ impl WidgetGateway {
448448
{
449449
return Err("policy_denied: local API scope is not supported".to_string());
450450
}
451+
let route_scopes = local_api_route_scopes(path, method.as_str())
452+
.ok_or_else(|| "policy_denied: local API route is not allowed for widgets".to_string())?;
451453
let conn = self.db.lock().map_err(|e| e.to_string())?;
452454
let permissions = db::get_widget_permissions(&conn, &request.widget_id)
453455
.map_err(|e| format!("provider_error: {e}"))?;
@@ -457,10 +459,22 @@ impl WidgetGateway {
457459
{
458460
return Err("permission_denied: local-api:call permission is required".to_string());
459461
}
462+
if scopes.iter().any(|scope| !route_scopes.contains(&scope.as_str())) {
463+
return Err("policy_denied: requested scope is not allowed for this route".to_string());
464+
}
465+
let granted_scopes = route_scopes
466+
.iter()
467+
.copied()
468+
.filter(|scope| permissions.iter().any(|permission| permission == scope))
469+
.map(str::to_string)
470+
.collect::<Vec<_>>();
471+
if scopes.iter().any(|scope| !granted_scopes.iter().any(|granted| granted == scope)) {
472+
return Err("permission_denied: requested local API scope is not granted".to_string());
473+
}
460474
let token = crate::commands::extension_bridge_cmd::issue_api_token_impl(
461475
&conn,
462476
format!("Widget: {}", request.widget_id),
463-
scopes,
477+
granted_scopes,
464478
None,
465479
)?;
466480
drop(conn);
@@ -499,8 +513,17 @@ impl WidgetGateway {
499513
.as_deref()
500514
.ok_or_else(|| "invalid_request: resource URL is required".to_string())?;
501515
policy_firewall::is_target_allowed(target)?;
516+
let parsed_target = reqwest::Url::parse(target)
517+
.map_err(|_| "policy_denied: invalid resource URL".to_string())?;
518+
policy_firewall::validate_resolved_host(
519+
parsed_target
520+
.host_str()
521+
.ok_or_else(|| "policy_denied: resource URL has no host".to_string())?,
522+
parsed_target.port_or_known_default().unwrap_or(443),
523+
)?;
502524
let client = reqwest::blocking::Client::builder()
503525
.timeout(std::time::Duration::from_secs(PROXY_TIMEOUT_SECS))
526+
.redirect(reqwest::redirect::Policy::none())
504527
.build()
505528
.map_err(|e| format!("provider_error: {e}"))?;
506529
let response = client.get(target).send().map_err(|e| {
@@ -701,6 +724,26 @@ impl WidgetGateway {
701724
}
702725
}
703726

727+
fn local_api_route_scopes(path: &str, method: &str) -> Option<&'static [&'static str]> {
728+
match (method, path) {
729+
("GET", "/api/status")
730+
| ("GET", "/api/screen-time/today")
731+
| ("GET", "/api/screen-time/range")
732+
| ("GET", "/api/categories") => Some(&["screen-time:read"]),
733+
("GET", "/api/browser/link") => Some(&["browser:read"]),
734+
("POST", "/api/browser/session") => Some(&["browser:write"]),
735+
("GET", "/api/vscode/stats/today")
736+
| ("GET", "/api/vscode/stats/range")
737+
| ("GET", "/api/vscode/languages/range")
738+
| ("GET", "/api/vscode/projects/range")
739+
| ("GET", "/api/vscode/enabled") => Some(&["vscode:read"]),
740+
("POST", "/api/vscode/sessions") | ("POST", "/api/vscode/enabled") => {
741+
Some(&["vscode:write"])
742+
}
743+
_ => None,
744+
}
745+
}
746+
704747
fn is_known_widget_event(event: &str) -> bool {
705748
matches!(
706749
event,

0 commit comments

Comments
 (0)