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
65 changes: 62 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ jobs:
rclone_arch: arm64
rclone_sha256: 97685285c9ad6a0cf17d5844115d2a67245af6444db672187074bd9c358de419
runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
Expand Down Expand Up @@ -453,6 +452,47 @@ jobs:
Remove-Item $shipping -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive "release/${{ matrix.asset }}.zip" $shipping

- name: Install pinned Inno Setup compiler
if: runner.os == 'Windows' && contains(inputs.tag || github.ref_name, '-')
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$expected = '6.4.3'
$iscc = Get-Command iscc.exe -ErrorAction SilentlyContinue
if (-not $iscc) {
if (-not (Get-Command choco.exe -ErrorAction SilentlyContinue)) {
throw 'Neither ISCC.exe nor Chocolatey is available for the pinned Inno Setup toolchain'
}
choco install innosetup --version $expected --yes --no-progress
$iscc = Get-Command iscc.exe -ErrorAction SilentlyContinue
if (-not $iscc) {
$iscc = Get-ChildItem "$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe" -ErrorAction SilentlyContinue |
Select-Object -First 1
}
}
if (-not $iscc) { throw 'ISCC.exe was not found after installing pinned Inno Setup' }
$version = (Get-Item $iscc.Source).VersionInfo.FileVersion
if (-not $version.StartsWith($expected)) {
throw "Unexpected Inno Setup compiler version '$version'; expected $expected"
}
"ISCC_PATH=$($iscc.Source)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append

- name: Build unsigned per-user installer for prerelease
if: runner.os == 'Windows' && contains(inputs.tag || github.ref_name, '-')
shell: pwsh
env:
RELEASE_VERSION: ${{ inputs.tag || github.ref_name }}
run: |
$version = $env:RELEASE_VERSION.TrimStart('v')
$arch = if ('${{ matrix.asset }}' -like '*-arm64') { 'arm64' } else { 'x64' }
& ./distribution/windows-installer/build-installer.ps1 `
-InputExe "target/onefile/SSHMountMate.exe" `
-OutputDir "release" `
-AppVersion $version `
-Arch $arch `
-IsccPath ($env:ISCC_PATH ?? 'iscc.exe')
if ($LASTEXITCODE -ne 0) { throw 'per-user installer build failed' }

- name: Exercise packaged update and rollback
if: runner.os == 'Linux'
shell: bash
Expand Down Expand Up @@ -515,10 +555,17 @@ jobs:
path: release/${{ matrix.asset }}.zip
if-no-files-found: error

- name: Upload unsigned prerelease installer
if: runner.os == 'Windows' && contains(inputs.tag || github.ref_name, '-')
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ matrix.asset }}-setup
path: release/${{ matrix.asset }}-setup.exe
if-no-files-found: error

release-set:
needs: build
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
Expand All @@ -530,6 +577,8 @@ jobs:
merge-multiple: true
- name: Validate release set
shell: bash
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
run: |
set -euo pipefail
expected=(
Expand All @@ -544,7 +593,17 @@ jobs:
test -s "release-assets/${asset}"
done
test "$(find release-assets -maxdepth 1 -name 'SSHMountMate-*.zip' | wc -l)" -eq 6
(cd release-assets && sha256sum SSHMountMate-*.zip > SHA256SUMS.txt)
version="${RELEASE_TAG#v}"
setup_count="$(find release-assets -maxdepth 1 -type f -name 'SSHMountMate-windows-*-setup.exe' | wc -l)"
case "${version}" in
*-*)
test "${setup_count}" -eq 2
test -s release-assets/SSHMountMate-windows-x64-setup.exe
test -s release-assets/SSHMountMate-windows-arm64-setup.exe
;;
*) test "${setup_count}" -eq 0 ;;
esac
(cd release-assets && sha256sum SSHMountMate-* > SHA256SUMS.txt)
(cd release-assets && sha256sum --check SHA256SUMS.txt)

- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/rust-rewrite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- run: cargo test --workspace --all-features
- name: Validate Windows installer contract
shell: bash
run: bash tests/windows_installer_static.sh
- name: Verify live stable and prerelease update channels
env:
GITHUB_TOKEN: ${{ github.token }}
Expand Down
8 changes: 5 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
resolver = "2"

[workspace.package]
version = "0.4.4-alpha.4"
version = "0.6.0-alpha.1"
edition = "2024"
rust-version = "1.88"
license = "MIT"
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ It uses rclone for the actual mount operation and provides a small GUI around th
- Check for rclone and platform mount dependencies.
- Bundle and verify the official rclone binary in release builds.
- Configure global rclone VFS cache options in the GUI.
- Show mount status, capacity usage, logs, and common actions per connection.
- Show mount status, capacity usage, Lustre project/user/group quotas, logs, and common actions per connection.
- Show the real rclone upload queue and remote-transfer progress after local file copies appear complete.
- Verify remote directory contents on refresh and expose refresh/transfer actions from connection-card context menus.
- Mount or unmount all saved connections from the main window.
Expand Down Expand Up @@ -334,6 +334,12 @@ Refresh clears the VFS directory cache, actively reloads the requested directory

Right-click a connection card for Open, Refresh, Transfers, and Log actions. Settings can register Refresh and Transfers commands in Windows Explorer, macOS Finder Quick Actions, and Nautilus, Nemo, or KDE file managers on Linux. The commands point back to the same SSH MountMate executable; no helper program is installed. A short-lived file-manager process forwards its request to the running app over authenticated loopback IPC and exits.

The Windows prerelease also provides unsigned per-user x64 and ARM64 setup packages. The installed edition lives under `%LOCALAPPDATA%\Programs\SSH MountMate`, does not require elevation, and can observe Explorer navigation into mounted directories. It schedules cache-only VFS refreshes in the background, with bounded concurrency and deduplication, so opening a directory is never blocked. Portable ZIP builds remain available and do not enable passive Explorer observation. Because the setup executables are unsigned, verify `SHA256SUMS.txt` before running them.

## Capacity And Lustre Quotas

Mounted cards show used and total capacity. On Lustre paths, the Quota action opens project, current-user, and primary-group block and inode quota details, including soft/hard limits and grace state. The probe uses one authenticated SSH invocation and keeps successful scopes visible when another scope is unavailable. Missing `lfs`, non-Lustre filesystems, missing project IDs, and permission errors are reported without affecting the mount. Interactive/shared SSH connections reuse their verified connector instead of opening a second authentication prompt.

The Rust application keeps a native system-tray icon on Windows, a menu-bar item on macOS, and an AppIndicator on supported Linux desktops. Closing the main window hides it without stopping mounts or transfer monitoring. The tray menu can restore the main window, open Transfers, mount or unmount all connections, and explicitly exit the interface. Exit asks for confirmation when uploads are active or cloud state is unknown; rclone mount processes remain independent of the GUI.

## Capacity Display
Expand Down
4 changes: 3 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,9 @@ SSH MountMate 会尽量启用 rclone 的 host key 校验。

## 容量显示

对已挂载连接,SSH MountMate 会在连接卡片上显示已用容量和总容量。对于 Lustre 路径,程序会优先用 `lfs project -d` 读取远端目录的 project ID,再用 `lfs quota -p` 读取 project quota。如果路径不在 Lustre 上、远端没有 `lfs`,或该 project 没有非零 hard block limit,则回退使用 `rclone about`;当 SSH 配置支持非交互登录时,还会继续尝试远端 `df -Pk`。
对已挂载连接,SSH MountMate 会在连接卡片上显示已用容量和总容量。对于 Lustre 路径,卡片上的“配额”按钮会展示 project、当前用户和主组的空间及 inode 配额,包括软限制、硬限制和宽限期状态。程序通过一次已认证的 SSH 调用查询全部范围;其中一个范围不可用时,其他成功结果仍会保留。远端缺少 `lfs`、路径不在 Lustre 上、project ID 缺失或权限不足时会明确显示原因,但不会影响挂载。交互式共享 SSH 会复用已验证的连接,不会另开认证提示。

Windows 预发布版还提供未签名的 x64 和 ARM64 每用户安装包。安装版位于 `%LOCALAPPDATA%\Programs\SSH MountMate`,不需要管理员权限,并可感知资源管理器进入已挂载目录的行为。缓存刷新在后台按有限并发和去重规则异步执行,不会阻塞目录打开。便携 ZIP 继续保留,并且不会启用这种被动感知。安装包尚未签名,运行前请核对 `SHA256SUMS.txt`。

## 设置

Expand Down
66 changes: 65 additions & 1 deletion crates/mountmate-app/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ pub(crate) enum LaunchAction {
UnregisterFileManagerMenu,
RegisterLoginStartup,
UnregisterLoginStartup,
InstallerCheckVersion {
requested: String,
recorded: String,
},
InstallerUninstallPreflight,
Help,
Version,
Licenses,
Expand Down Expand Up @@ -56,6 +61,7 @@ pub(crate) fn parse(arguments: impl IntoIterator<Item = String>) -> Result<Launc
let mut update_helper_token = None;
let mut update_health_marker = None;
let mut update_health_token = None;
let mut installer_recorded_version = None;
let mut index = 0;
while index < arguments.len() {
let argument = &arguments[index];
Expand All @@ -74,6 +80,22 @@ pub(crate) fn parse(arguments: impl IntoIterator<Item = String>) -> Result<Launc
}
"--register-login-startup" => Some(LaunchAction::RegisterLoginStartup),
"--unregister-login-startup" => Some(LaunchAction::UnregisterLoginStartup),
"--installer-check-version" => {
let requested = next_value(&arguments, &mut index, argument)?;
Some(LaunchAction::InstallerCheckVersion {
requested,
recorded: String::new(),
})
}
"--installer-recorded-version" => {
set_once(
&mut installer_recorded_version,
next_value(&arguments, &mut index, argument)?,
argument,
)?;
None
}
"--installer-uninstall-preflight" => Some(LaunchAction::InstallerUninstallPreflight),
"--show-main" => Some(LaunchAction::Gui {
command: AppCommand::ShowMain,
update_health: None,
Expand Down Expand Up @@ -158,6 +180,19 @@ pub(crate) fn parse(arguments: impl IntoIterator<Item = String>) -> Result<Launc
}
}
match &mut action {
LaunchAction::InstallerCheckVersion { recorded, .. } => {
if recorded.is_empty() {
*recorded = installer_recorded_version.ok_or_else(|| {
"--installer-check-version requires a recorded version".to_owned()
})?;
}
if update_helper_token.is_some()
|| update_health_marker.is_some()
|| update_health_token.is_some()
{
return Err("internal update arguments require their matching command".into());
}
}
LaunchAction::RunUpdateHelper(authorization) => {
authorization.token = update_helper_token
.ok_or_else(|| "--run-update-helper requires --update-helper-token".to_owned())?;
Expand All @@ -183,7 +218,8 @@ pub(crate) fn parse(arguments: impl IntoIterator<Item = String>) -> Result<Launc
};
}
_ => {
if update_helper_token.is_some()
if installer_recorded_version.is_some()
|| update_helper_token.is_some()
|| update_health_marker.is_some()
|| update_health_token.is_some()
{
Expand Down Expand Up @@ -331,6 +367,23 @@ mod tests {
parse(args(&["--register-login-startup"])).unwrap(),
LaunchAction::RegisterLoginStartup
);
assert_eq!(
parse(args(&[
"--installer-check-version",
"0.6.0-alpha.1",
"--installer-recorded-version",
"0.6.0-alpha.1",
]))
.unwrap(),
LaunchAction::InstallerCheckVersion {
requested: "0.6.0-alpha.1".into(),
recorded: "0.6.0-alpha.1".into(),
}
);
assert_eq!(
parse(args(&["--installer-uninstall-preflight"])).unwrap(),
LaunchAction::InstallerUninstallPreflight
);
}

#[test]
Expand Down Expand Up @@ -378,6 +431,16 @@ mod tests {
assert!(parse(args(&["--mount-all", "--show-main"])).is_err());
assert!(parse(args(&["--relative-dir", "folder"])).is_err());
assert!(parse(args(&["--unknown"])).is_err());
assert!(parse(args(&["--installer-check-version", "0.6.0-alpha.1"])).is_err());
assert!(
parse(args(&[
"--installer-check-version",
"0.6.0-alpha.1",
"0.6.0-alpha.1",
]))
.is_err()
);
assert!(parse(args(&["--installer-recorded-version", "0.6.0-alpha.1"])).is_err());
}

#[test]
Expand All @@ -390,6 +453,7 @@ mod tests {
assert!(help().contains("\n -V, --version"));
assert!(!help().contains("update-helper"));
assert!(!help().contains("update-health"));
assert!(!help().contains("installer-check-version"));
}

#[test]
Expand Down
36 changes: 36 additions & 0 deletions crates/mountmate-app/src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,11 @@ pub(crate) enum TextKey {
MountAll,
MountConnectionForTransfers,
Mountpoint,
NavigationRefresh,
NavigationRefreshActive,
NavigationRefreshDisabled,
NavigationRefreshHelp,
NavigationRefreshUnavailable,
Name,
NewConnection,
NoMountedConnections,
Expand Down Expand Up @@ -544,6 +549,15 @@ fn english(key: TextKey) -> &'static str {
"Mount a connection to inspect its cloud transfer state"
}
TextKey::Mountpoint => "Mountpoint (Auto by default)",
TextKey::NavigationRefresh => "Refresh Explorer folders automatically",
TextKey::NavigationRefreshActive => "Active (installed Windows edition; best effort)",
TextKey::NavigationRefreshDisabled => "Disabled",
TextKey::NavigationRefreshHelp => {
"Installed Windows edition only. Navigation is observed out of process; refreshes run in the background and never show a modal error."
}
TextKey::NavigationRefreshUnavailable => {
"Unavailable (installed Windows edition required; use Refresh now for an explicit refresh)"
}
TextKey::Name => "Name",
TextKey::NewConnection => "New connection",
TextKey::NoMountedConnections => "No mounted connections",
Expand Down Expand Up @@ -676,6 +690,15 @@ fn chinese(key: TextKey) -> &'static str {
TextKey::MountAll => "全部挂载",
TextKey::MountConnectionForTransfers => "挂载连接后可查看其云端传输状态",
TextKey::Mountpoint => "挂载点(默认自动选择)",
TextKey::NavigationRefresh => "自动刷新资源管理器文件夹",
TextKey::NavigationRefreshActive => "已启用(仅 Windows 安装版,尽力而为)",
TextKey::NavigationRefreshDisabled => "已禁用",
TextKey::NavigationRefreshHelp => {
"仅适用于 Windows 安装版。导航观察在进程外进行;刷新在后台运行,不会弹出模态错误。"
}
TextKey::NavigationRefreshUnavailable => {
"不可用(需要 Windows 安装版;可使用“立即刷新”执行明确刷新)"
}
TextKey::Name => "名称",
TextKey::NewConnection => "新建连接",
TextKey::NoMountedConnections => "没有已挂载的连接",
Expand Down Expand Up @@ -777,6 +800,19 @@ mod tests {
);
}

#[test]
fn navigation_refresh_settings_have_bilingual_labels() {
for key in [
TextKey::NavigationRefresh,
TextKey::NavigationRefreshHelp,
TextKey::NavigationRefreshActive,
TextKey::NavigationRefreshUnavailable,
] {
assert!(!Locale::English.text(key).is_empty());
assert!(!Locale::Chinese.text(key).is_empty());
}
}

#[test]
fn choice_keeps_typed_value_and_localized_label() {
let choice = Locale::Chinese.choice(AuthMethod::Key, "私钥");
Expand Down
Loading
Loading