Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。

## [Unreleased]

### 修复

- 同步:本地文件意外消失且远端版本令牌变化时,不再把远端唯一副本误删;「本地全部消失」安全阀把待恢复文件也计入。
- 设置文件被杀软或同步盘短暂锁住时,本次运行不再用默认值覆盖原配置;读不出来时会提示「修改不会保存」。
- OneDrive「按需文件」的云端占位符不再被误判为符号链接,保存与历史恢复正常。
- Markdown 模式下正文里形如 `- 10:00`、`### 10:00` 的行不再被拆成新条目,删除幽灵条目也不会带走正文。
- 托盘「打开今日笔记」拒绝符号链接;Git 自动提交只认真实 `.git` 目录,gitfile 重定向不再操作目录外仓库。
- 笔记数量多时自动提交不再因 Windows 32K 命令行上限失败。
- 合并结果超过 2 MiB 时改为保留冲突副本;同步警告会显示在状态行与悬停提示里。
- 网络共享笔记目录的「定位」不再因 `\\?\UNC\` 前缀失败。
- WebDAV 回读校验不再无条件重写;Hash 版本复核后用 GET 的强 ETag 做条件删除/写入,降低覆盖并发编辑的风险。
- 条目编辑保存期间的新输入不再丢失;重开设置面板不再清空未保存的同步表单;跨 DPI 多屏恢复使用保存位置所在显示器的缩放。
- 清除同步密码时一并删除 0.5.x 旧凭据,避免迁移回退把已清除的密码重新搬回来。

## [0.6.1] - 2026-11-04

### 修复
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ pub fn persist_sync(app: &AppHandle) -> Result<(), String> {
}

fn write_snapshot(state: &AppState, path: &Path, seq: u64, snapshot: &Settings) -> bool {
if state.settings_read_failed {
// 启动时读不出来:内存里是默认值,写回会把原配置覆盖掉。
log::error!("设置文件启动时读取失败,本次运行不写回设置");
return false;
}
write_snapshot_locked(
path,
seq,
Expand Down
54 changes: 34 additions & 20 deletions src-tauri/src/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,15 @@ pub(crate) fn io_guard(state: &AppState) -> MutexGuard<'_, ()> {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}

#[cfg(windows)]
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;

/// 路径本身是不是符号链接/重解析点;只查路径,不跟随目标。
/// 路径本身是不是符号链接/目录联接;只查路径,不跟随目标。
///
/// 不能只看 `FILE_ATTRIBUTE_REPARSE_POINT`:OneDrive「按需文件」的云端占位符
/// 也带这个属性(实测 0x401620),但它们就是普通笔记文件。`FileType::is_symlink`
/// 会继续检查重解析标签,只把符号链接与 junction 算作链接。
pub(crate) fn is_reparse_point(path: &Path) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
fs::symlink_metadata(path)
.map(|meta| meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0)
.unwrap_or(false)
}
#[cfg(not(windows))]
{
fs::symlink_metadata(path)
.map(|meta| meta.file_type().is_symlink())
.unwrap_or(false)
}
fs::symlink_metadata(path)
.map(|meta| meta.file_type().is_symlink())
.unwrap_or(false)
}

/// 拒绝写入重解析点,避免把笔记/同步内容写到链接目标(可能是目录外文件)。
Expand Down Expand Up @@ -92,12 +83,20 @@ pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
result
}

/// explorer 不认 `\\?\` verbatim 前缀,遇到会直接报「找不到文件」;先剥掉再传。
/// UNC 路径的 verbatim 形式是 `\\?\UNC\server\share`,剥完要还原成 `\\server\share`。
fn explorer_path(display: &str) -> String {
if let Some(rest) = display.strip_prefix(r"\\?\UNC\") {
format!(r"\\{rest}")
} else {
display.strip_prefix(r"\\?\").unwrap_or(display).to_string()
}
}

fn open_with_explorer(path: &Path) -> Result<(), String> {
// explorer 不认 `\\?\` verbatim 前缀,遇到会直接报「找不到文件」;先剥掉再传
let display = path.to_string_lossy();
let target = display.strip_prefix(r"\\?\").unwrap_or(&display);
std::process::Command::new("explorer")
.arg(target)
.arg(explorer_path(&display))
.spawn()
.map(|_| ())
.map_err(|err| err.to_string())
Expand Down Expand Up @@ -165,6 +164,9 @@ fn open_with_default_app(path: &Path) -> Result<(), String> {
fn ensure_note_file(dir: &Path, date: &str) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir).map_err(|err| err.to_string())?;
let path = dir.join(format!("{date}.md"));
// 必须先检查再 create_new:Windows 上 create_new 会跟随悬空的符号链接,
// 在链接目标处创建文件;等写完再拒绝,目录外已经被写入了。
reject_reparse(&path).map_err(|err| format!("今日笔记不可用:{err}"))?;
// create_new 而不是 exists + write:文件已存在时保持原样,不会截断当天笔记
match std::fs::OpenOptions::new()
.write(true)
Expand All @@ -179,6 +181,8 @@ fn ensure_note_file(dir: &Path, date: &str) -> Result<PathBuf, String> {
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(format!("创建今日笔记失败:{err}")),
}
// 创建后再检查一次,缩小检查与打开之间的竞态窗口。
reject_reparse(&path).map_err(|err| format!("今日笔记不可用:{err}"))?;
Ok(path)
}

Expand Down Expand Up @@ -513,6 +517,16 @@ mod tests {
);
}

#[test]
fn explorer_path_strips_verbatim_prefixes() {
assert_eq!(explorer_path(r"\\?\C:\notes\a.md"), r"C:\notes\a.md");
assert_eq!(
explorer_path(r"\\?\UNC\nas\notes\a.md"),
r"\\nas\notes\a.md"
);
assert_eq!(explorer_path(r"\\nas\notes\a.md"), r"\\nas\notes\a.md");
}

#[test]
fn resolve_within_rejects_missing_file() {
let dir = temp_dir("resolve-missing");
Expand Down
65 changes: 56 additions & 9 deletions src-tauri/src/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ fn overlap_area(a: Rect, b: Rect) -> i64 {
}
}

/// 保存位置所在显示器(完整矩形,不是工作区)及其当前缩放。
/// 用工作区会把落在任务栏区域的坐标判成「没有显示器」。
fn monitor_at(window: &WebviewWindow, x: i32, y: i32) -> Option<(Rect, f64)> {
window
.available_monitors()
.ok()?
.into_iter()
.find_map(|monitor| {
let position = monitor.position();
let size = monitor.size();
let rect = Rect {
x: position.x,
y: position.y,
w: size.width,
h: size.height,
};
(x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom())
.then_some((rect, monitor.scale_factor()))
})
}

fn monitor_rects(window: &WebviewWindow) -> Vec<Rect> {
window
.available_monitors()
Expand Down Expand Up @@ -126,8 +147,14 @@ pub(crate) fn clamp_rect(rect: Rect, monitors: &[Rect]) -> (i32, i32) {
}

/// 把保存的物理像素几何从旧 DPI 缩放换算到当前缩放,保证逻辑位置/尺寸不变。
/// 坐标只缩放相对显示器原点的偏移,否则副屏原点也会被乘一遍。
/// 老配置没有缩放字段(None)或数值异常时原样返回。
pub(crate) fn scale_geometry(rect: Rect, saved_scale: Option<f64>, current_scale: f64) -> Rect {
pub(crate) fn scale_geometry(
rect: Rect,
saved_scale: Option<f64>,
current_scale: f64,
origin: (i32, i32),
) -> Rect {
let factor = match saved_scale {
Some(saved) if saved > 0.0 && current_scale > 0.0 => current_scale / saved,
_ => return rect,
Expand All @@ -136,17 +163,17 @@ pub(crate) fn scale_geometry(rect: Rect, saved_scale: Option<f64>, current_scale
return rect;
}

let point = |value: i32| -> i32 {
(value as f64 * factor)
let point = |value: i32, base: i32| -> i32 {
(base as f64 + (value as f64 - base as f64) * factor)
.round()
.clamp(i32::MIN as f64, i32::MAX as f64) as i32
};
let extent =
|value: u32| -> u32 { (value as f64 * factor).round().clamp(0.0, u32::MAX as f64) as u32 };

Rect {
x: point(rect.x),
y: point(rect.y),
x: point(rect.x, origin.0),
y: point(rect.y, origin.1),
w: extent(rect.w),
h: extent(rect.h),
}
Expand Down Expand Up @@ -208,8 +235,13 @@ pub fn apply_initial_geometry(app: &AppHandle) {
match saved {
Some(geometry) => {
let monitors = monitor_rects(&window);
// 物理像素会随 DPI 缩放变化,按保存时的缩放换算回同样的逻辑位置/尺寸
// 物理像素会随 DPI 缩放变化,按保存时的缩放换算回同样的逻辑位置/尺寸。
// 窗口启动时在主屏,scale_factor() 是主屏的;保存的几何可能在另一块屏,
// 必须用保存位置那块屏的当前缩放。
let current_scale = window.scale_factor().unwrap_or(1.0);
let (origin, target_scale) = monitor_at(&window, geometry.x, geometry.y)
.map(|(rect, scale)| ((rect.x, rect.y), scale))
.unwrap_or(((0, 0), current_scale));
let scaled = scale_geometry(
Rect {
x: geometry.x,
Expand All @@ -218,7 +250,8 @@ pub fn apply_initial_geometry(app: &AppHandle) {
h: geometry.h,
},
geometry.scale,
current_scale,
target_scale,
origin,
);
let (x, y) = clamp_rect(scaled, &monitors);
let (w, h) = clamp_size(scaled.w, scaled.h, x, y, &monitors);
Expand Down Expand Up @@ -372,17 +405,31 @@ mod tests {
h: 360,
};

let scaled = scale_geometry(rect, Some(1.0), 1.5);
let scaled = scale_geometry(rect, Some(1.0), 1.5, (0, 0));
assert_eq!(
(scaled.x, scaled.y, scaled.w, scaled.h),
(150, 75, 720, 540)
);

// 老配置没有 scale 字段:保持原样,不能凭空放大
let untouched = scale_geometry(rect, None, 2.0);
let untouched = scale_geometry(rect, None, 2.0, (0, 0));
assert_eq!((untouched.x, untouched.y, untouched.w), (100, 50, 480));
}

#[test]
fn scale_geometry_keeps_the_monitor_origin_fixed() {
let rect = Rect {
x: 2000,
y: 100,
w: 480,
h: 360,
};
// 副屏原点 1920 不参与缩放,只缩放窗口相对原点的偏移。
let scaled = scale_geometry(rect, Some(1.0), 1.5, (1920, 0));
assert_eq!((scaled.x, scaled.y), (2040, 150));
assert_eq!((scaled.w, scaled.h), (720, 540));
}

#[test]
fn clamp_rect_survives_absurd_saved_size() {
let monitors = [Rect {
Expand Down
Loading