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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ All notable changes to ModbusSim are documented in this file.

---

## [0.17.7] - 2026-10-20

### Highlights / 亮点

- 📄 证书路径字段优先显示路径末尾(文件名),悬停查看完整路径,点击即可编辑整段路径。 / Certificate path fields now lead with the file name at the end of the path; hover shows the full path and clicking opens it for editing.
- 🖱️ macOS 文件选择框改为独立窗口,可随意拖动(包括拖到其他屏幕),不再以无法移动的 sheet 形式贴在主窗口上。 / The macOS file picker is now a free-standing window that can be dragged anywhere — even to another screen — instead of an immovable sheet attached to the main window.

### Fixed 修复

- 子站 TLS 接受 PEM 格式的 RSA 私钥,TLS 监听启动失败时向前端报告错误。 / Slave TLS accepts PEM-encoded RSA keys and reports listener startup failures to the frontend. (#18)
- 新建连接对话框中证书路径过长时只能看到路径开头,无法辨认所选文件;现在默认显示路径尾部。 / Long certificate paths in the new-connection dialog only showed the path head, hiding the chosen file; the path tail is now shown by default.
- macOS 证书文件选择框以 sheet 形式弹出且无法拖动;现改为不带父窗口的原生面板(Windows/Linux 行为不变)。 / The macOS certificate picker popped up as a sheet that could not be moved; it now uses a parentless native panel (Windows/Linux behavior unchanged).
- 证书/私钥输入框的占位提示与字段含义不符:服务器证书、服务器私钥曾显示“CA 证书路径”,客户端私钥曾显示“可选,用于 mTLS”。 / Placeholder texts mismatched their fields: server certificate/key showed the CA-certificate hint and the client key showed the mTLS hint.

## [0.17.6] - 2026-10-10

### Highlights / 亮点
Expand Down
3 changes: 2 additions & 1 deletion crates/modbusmaster-app/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "modbusmaster-app"
version = "0.17.6"
version = "0.17.7"
description = "ModbusMaster - Cross-platform Modbus TCP Master"
edition = "2021"
rust-version = "1.77.2"
Expand All @@ -23,6 +23,7 @@ tokio = { version = "1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
tauri-plugin-dialog = "2"
rfd = { version = "0.16", default-features = false }
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-store = "2"
Expand Down
23 changes: 23 additions & 0 deletions crates/modbusmaster-app/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1717,6 +1717,29 @@ pub fn list_serial_ports() -> Vec<transport::SerialPortInfo> {
transport::list_serial_ports()
}

/// Opens a native file picker that is NOT attached to the app window.
///
/// tauri-plugin-dialog passes the app window to rfd, which makes macOS show
/// the panel as a window sheet — and sheets cannot be moved. Building the rfd
/// dialog without a parent turns it into a free-standing, movable modal
/// panel. rfd itself marshals the call onto the main thread, so running this
/// on a blocking worker is safe on all platforms.
#[tauri::command]
pub async fn pick_certificate_file(filters: Vec<(String, Vec<String>)>) -> Option<String> {
tauri::async_runtime::spawn_blocking(move || {
let mut dialog = rfd::FileDialog::new();
for (name, extensions) in &filters {
dialog = dialog.add_filter(name, extensions);
}
dialog
.pick_file()
.map(|path| path.to_string_lossy().into_owned())
})
.await
.ok()
.flatten()
}

#[cfg(test)]
mod write_request_tests {
use super::*;
Expand Down
1 change: 1 addition & 0 deletions crates/modbusmaster-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub fn run() {
commands::convert_modbus_to_plc,
commands::calculate_crc16,
commands::calculate_lrc,
commands::pick_certificate_file,
commands::parse_hex,
// Project file commands
commands::save_project_file,
Expand Down
2 changes: 1 addition & 1 deletion crates/modbusmaster-app/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ModbusMaster",
"version": "0.17.6",
"version": "0.17.7",
"identifier": "com.modbusmaster.dev",
"build": {
"devUrl": "http://localhost:5174",
Expand Down
3 changes: 2 additions & 1 deletion crates/modbussim-app/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "modbussim-app"
version = "0.17.6"
version = "0.17.7"
description = "ModbusSim - Cross-platform Modbus Simulator"
edition = "2021"
rust-version = "1.77.2"
Expand All @@ -23,6 +23,7 @@ tokio = { version = "1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
rand = "0.9.2"
tauri-plugin-dialog = "2"
rfd = { version = "0.16", default-features = false }
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-store = "2"
Expand Down
23 changes: 23 additions & 0 deletions crates/modbussim-app/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2199,6 +2199,29 @@ mod tests {
}
}

/// Opens a native file picker that is NOT attached to the app window.
///
/// tauri-plugin-dialog passes the app window to rfd, which makes macOS show
/// the panel as a window sheet — and sheets cannot be moved. Building the rfd
/// dialog without a parent turns it into a free-standing, movable modal
/// panel. rfd itself marshals the call onto the main thread, so running this
/// on a blocking worker is safe on all platforms.
#[tauri::command]
pub async fn pick_certificate_file(filters: Vec<(String, Vec<String>)>) -> Option<String> {
tauri::async_runtime::spawn_blocking(move || {
let mut dialog = rfd::FileDialog::new();
for (name, extensions) in &filters {
dialog = dialog.add_filter(name, extensions);
}
dialog
.pick_file()
.map(|path| path.to_string_lossy().into_owned())
})
.await
.ok()
.flatten()
}

#[tauri::command]
pub fn save_text_export(path: String, content: String) -> Result<(), String> {
std::fs::write(path, content).map_err(|error| error.to_string())
Expand Down
1 change: 1 addition & 0 deletions crates/modbussim-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub fn run() {
commands::list_registers,
commands::export_registers,
commands::save_text_export,
commands::pick_certificate_file,
commands::import_registers,
// Log commands
commands::get_communication_logs,
Expand Down
2 changes: 1 addition & 1 deletion crates/modbussim-app/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ModbusSlave",
"version": "0.17.6",
"version": "0.17.7",
"identifier": "com.modbusslave.dev",
"build": {
"devUrl": "http://localhost:5175",
Expand Down
37 changes: 13 additions & 24 deletions frontend/src/components/NewConnectionDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
import { vModal } from 'shared-frontend'
import { ref, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { useI18n, showAlert } from 'shared-frontend'
import { useI18n, showAlert, PathField } from 'shared-frontend'

const { t } = useI18n()

Expand Down Expand Up @@ -66,13 +65,14 @@ async function refreshSerialPorts() {

async function pickFile(target: 'cert' | 'key' | 'ca' | 'pkcs12') {
try {
const path = await open({
// Goes through a custom command so macOS shows a free-standing (movable)
// panel instead of a window sheet.
const p = await invoke<string | null>('pick_certificate_file', {
filters: target === 'pkcs12'
? [{ name: 'PKCS#12', extensions: ['p12', 'pfx'] }]
: [{ name: 'PEM Certificate', extensions: ['pem', 'crt', 'key'] }],
? [['PKCS#12', ['p12', 'pfx']]]
: [['PEM Certificate', ['pem', 'crt', 'key']]],
})
if (!path) return
const p = path as string
if (!p) return
if (target === 'cert') tlsCertFile.value = p
else if (target === 'key') tlsKeyFile.value = p
else if (target === 'ca') tlsCaFile.value = p
Expand Down Expand Up @@ -172,24 +172,15 @@ async function submit() {
<template v-if="useTls">
<div class="modal-field">
<label>{{ t('dialog.serverCert') }}</label>
<div class="file-row">
<input v-model="tlsCertFile" type="text" :placeholder="t('dialog.caFilePlaceholder')" />
<button class="tool-btn" @click="pickFile('cert')">...</button>
</div>
<PathField v-model="tlsCertFile" :placeholder="t('dialog.serverCertPlaceholder')" @browse="pickFile('cert')" />
</div>
<div class="modal-field">
<label>{{ t('dialog.serverKey') }}</label>
<div class="file-row">
<input v-model="tlsKeyFile" type="text" :placeholder="t('dialog.caFilePlaceholder')" />
<button class="tool-btn" @click="pickFile('key')">...</button>
</div>
<PathField v-model="tlsKeyFile" :placeholder="t('dialog.serverKeyPlaceholder')" @browse="pickFile('key')" />
</div>
<div class="modal-field">
<label>{{ t('dialog.pkcs12File') }}</label>
<div class="file-row">
<input v-model="tlsPkcs12File" type="text" :placeholder="t('dialog.pkcs12FilePlaceholder')" />
<button class="tool-btn" @click="pickFile('pkcs12')">...</button>
</div>
<PathField v-model="tlsPkcs12File" :placeholder="t('dialog.pkcs12FilePlaceholder')" @browse="pickFile('pkcs12')" />
</div>
<div class="modal-field" v-if="tlsPkcs12File">
<label>{{ t('dialog.pkcs12Password') }}</label>
Expand All @@ -202,10 +193,7 @@ async function submit() {
</div>
<div class="modal-field" v-if="tlsRequireClientCert">
<label>{{ t('dialog.caFileClient') }}</label>
<div class="file-row">
<input v-model="tlsCaFile" type="text" :placeholder="t('dialog.caFilePlaceholder')" />
<button class="tool-btn" @click="pickFile('ca')">...</button>
</div>
<PathField v-model="tlsCaFile" :placeholder="t('dialog.caFilePlaceholder')" @browse="pickFile('ca')" />
</div>
</template>
</template>
Expand Down Expand Up @@ -289,10 +277,11 @@ async function submit() {
.modal-field { margin-bottom: 14px; }
.modal-field label { display: block; font-size: 12px; color: var(--c-subtext0); margin-bottom: 6px; }
.modal-field input[type="number"], .modal-field input[type="text"], .modal-field input[type="password"] {
box-sizing: border-box;
width: 100%; padding: 6px 10px; background: var(--c-surface0); border: 1px solid var(--c-surface1); border-radius: 4px; color: var(--c-text); font-size: 13px; outline: none;
}
.modal-field input:focus { border-color: var(--c-blue); }
.form-select { width: 100%; padding: 6px 10px; background: var(--c-surface0); border: 1px solid var(--c-surface1); border-radius: 4px; color: var(--c-text); font-size: 13px; outline: none; }
.form-select { box-sizing: border-box; width: 100%; padding: 6px 10px; background: var(--c-surface0); border: 1px solid var(--c-surface1); border-radius: 4px; color: var(--c-text); font-size: 13px; outline: none; }
.form-select:focus { border-color: var(--c-blue); }
.file-row { display: flex; gap: 4px; }
.file-row > input, .file-row > select { flex: 1; }
Expand Down
50 changes: 19 additions & 31 deletions master-frontend/src/components/NewConnectionDialog.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { useI18n, showAlert, vModal } from 'shared-frontend'
import { useI18n, showAlert, vModal, PathField } from 'shared-frontend'
import CommunicationSettings from './CommunicationSettings.vue'
import { defaultCommunicationOptions, validCommunicationOptions, type CommunicationOptions } from '../communication'

Expand Down Expand Up @@ -134,13 +133,14 @@ async function refreshSerialPorts() {

async function pickFile(target: 'cert' | 'key' | 'ca' | 'pkcs12') {
try {
const path = await open({
// Goes through a custom command so macOS shows a free-standing (movable)
// panel instead of a window sheet.
const p = await invoke<string | null>('pick_certificate_file', {
filters: target === 'pkcs12'
? [{ name: 'PKCS#12', extensions: ['p12', 'pfx'] }]
: [{ name: 'PEM Certificate', extensions: ['pem', 'crt', 'key'] }],
? [['PKCS#12', ['p12', 'pfx']]]
: [['PEM Certificate', ['pem', 'crt', 'key']]],
})
if (!path) return
const p = path as string
if (!p) return
if (target === 'cert') tlsCertFile.value = p
else if (target === 'key') tlsKeyFile.value = p
else if (target === 'ca') tlsCaFile.value = p
Expand Down Expand Up @@ -288,34 +288,22 @@ async function submit() {
<input type="checkbox" v-model="tlsAcceptInvalidCerts" aria-describedby="tls-compatibility-hint" /> {{ t('dialog.acceptInvalidCerts') }}
</label>
<div id="tls-compatibility-hint" class="form-hint">{{ t('dialog.tlsCompatibilityHint') }}</div>
<label class="form-label">
<div class="form-label">
{{ t('dialog.caFile') }}
<div class="file-row">
<input v-model="tlsCaFile" class="form-input" type="text" :placeholder="t('dialog.caFilePlaceholder')" />
<button type="button" class="tool-btn" @click="pickFile('ca')">...</button>
</div>
</label>
<label class="form-label">
<PathField v-model="tlsCaFile" :placeholder="t('dialog.caFilePlaceholder')" @browse="pickFile('ca')" />
</div>
<div class="form-label">
{{ t('dialog.clientCert') }}
<div class="file-row">
<input v-model="tlsCertFile" class="form-input" type="text" :placeholder="t('dialog.clientCertPlaceholder')" />
<button type="button" class="tool-btn" @click="pickFile('cert')">...</button>
</div>
</label>
<label class="form-label">
<PathField v-model="tlsCertFile" :placeholder="t('dialog.clientCertPlaceholder')" @browse="pickFile('cert')" />
</div>
<div class="form-label">
{{ t('dialog.clientKey') }}
<div class="file-row">
<input v-model="tlsKeyFile" class="form-input" type="text" :placeholder="t('dialog.clientCertPlaceholder')" />
<button type="button" class="tool-btn" @click="pickFile('key')">...</button>
</div>
</label>
<label class="form-label">
<PathField v-model="tlsKeyFile" :placeholder="t('dialog.clientKeyPlaceholder')" @browse="pickFile('key')" />
</div>
<div class="form-label">
{{ t('dialog.pkcs12File') }}
<div class="file-row">
<input v-model="tlsPkcs12File" class="form-input" type="text" :placeholder="t('dialog.pkcs12FilePlaceholder')" />
<button type="button" class="tool-btn" @click="pickFile('pkcs12')">...</button>
</div>
</label>
<PathField v-model="tlsPkcs12File" :placeholder="t('dialog.pkcs12FilePlaceholder')" @browse="pickFile('pkcs12')" />
</div>
<label class="form-label" v-if="tlsPkcs12File">
{{ t('dialog.pkcs12Password') }}
<input v-model="tlsPkcs12Password" class="form-input" type="password" :placeholder="t('dialog.passwordPlaceholder')" />
Expand Down
Loading
Loading