diff --git a/CHANGELOG.md b/CHANGELOG.md index 013c671..97872d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 / 亮点 diff --git a/crates/modbusmaster-app/Cargo.toml b/crates/modbusmaster-app/Cargo.toml index d363835..9e0c884 100644 --- a/crates/modbusmaster-app/Cargo.toml +++ b/crates/modbusmaster-app/Cargo.toml @@ -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" @@ -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" diff --git a/crates/modbusmaster-app/src/commands.rs b/crates/modbusmaster-app/src/commands.rs index 3c21e5f..15cb4e8 100644 --- a/crates/modbusmaster-app/src/commands.rs +++ b/crates/modbusmaster-app/src/commands.rs @@ -1717,6 +1717,29 @@ pub fn list_serial_ports() -> Vec { 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)>) -> Option { + 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::*; diff --git a/crates/modbusmaster-app/src/lib.rs b/crates/modbusmaster-app/src/lib.rs index 0eed61a..c155929 100644 --- a/crates/modbusmaster-app/src/lib.rs +++ b/crates/modbusmaster-app/src/lib.rs @@ -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, diff --git a/crates/modbusmaster-app/tauri.conf.json b/crates/modbusmaster-app/tauri.conf.json index 4b4ac55..650bc79 100644 --- a/crates/modbusmaster-app/tauri.conf.json +++ b/crates/modbusmaster-app/tauri.conf.json @@ -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", diff --git a/crates/modbussim-app/Cargo.toml b/crates/modbussim-app/Cargo.toml index f8f63f5..68c6192 100644 --- a/crates/modbussim-app/Cargo.toml +++ b/crates/modbussim-app/Cargo.toml @@ -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" @@ -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" diff --git a/crates/modbussim-app/src/commands.rs b/crates/modbussim-app/src/commands.rs index 2b616cc..7126928 100644 --- a/crates/modbussim-app/src/commands.rs +++ b/crates/modbussim-app/src/commands.rs @@ -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)>) -> Option { + 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()) diff --git a/crates/modbussim-app/src/lib.rs b/crates/modbussim-app/src/lib.rs index c199449..3496372 100644 --- a/crates/modbussim-app/src/lib.rs +++ b/crates/modbussim-app/src/lib.rs @@ -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, diff --git a/crates/modbussim-app/tauri.conf.json b/crates/modbussim-app/tauri.conf.json index 19dfbd1..03a6792 100644 --- a/crates/modbussim-app/tauri.conf.json +++ b/crates/modbussim-app/tauri.conf.json @@ -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", diff --git a/frontend/src/components/NewConnectionDialog.vue b/frontend/src/components/NewConnectionDialog.vue index 0ebee80..d0e2c29 100644 --- a/frontend/src/components/NewConnectionDialog.vue +++ b/frontend/src/components/NewConnectionDialog.vue @@ -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() @@ -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('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 @@ -172,24 +172,15 @@ async function submit() { @@ -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; } diff --git a/master-frontend/src/components/NewConnectionDialog.vue b/master-frontend/src/components/NewConnectionDialog.vue index 3c60ec3..5d11a93 100644 --- a/master-frontend/src/components/NewConnectionDialog.vue +++ b/master-frontend/src/components/NewConnectionDialog.vue @@ -1,8 +1,7 @@ + + + + diff --git a/shared-frontend/src/i18n/locales/en-US.ts b/shared-frontend/src/i18n/locales/en-US.ts index 4204d89..21c3751 100644 --- a/shared-frontend/src/i18n/locales/en-US.ts +++ b/shared-frontend/src/i18n/locales/en-US.ts @@ -353,7 +353,10 @@ const messages: Messages = { rtuSerial: 'RTU (Serial)', asciiSerial: 'ASCII (Serial)', caFilePlaceholder: 'Path to CA certificate', + serverCertPlaceholder: 'Path to server certificate', + serverKeyPlaceholder: 'Path to server private key', clientCertPlaceholder: 'Optional, for mTLS', + clientKeyPlaceholder: 'Optional, for mTLS', pkcs12FilePlaceholder: 'Optional, takes precedence over PEM', passwordPlaceholder: 'Password', optional: 'Optional', diff --git a/shared-frontend/src/i18n/locales/zh-CN.ts b/shared-frontend/src/i18n/locales/zh-CN.ts index 764ade1..604a8ce 100644 --- a/shared-frontend/src/i18n/locales/zh-CN.ts +++ b/shared-frontend/src/i18n/locales/zh-CN.ts @@ -356,7 +356,10 @@ const messages = { rtuSerial: 'RTU (串口)', asciiSerial: 'ASCII (串口)', caFilePlaceholder: 'CA 证书路径', + serverCertPlaceholder: '服务器证书路径', + serverKeyPlaceholder: '服务器私钥路径', clientCertPlaceholder: '可选,用于 mTLS', + clientKeyPlaceholder: '可选,用于 mTLS', pkcs12FilePlaceholder: '可选,优先于 PEM', passwordPlaceholder: '密码', optional: '可选', diff --git a/shared-frontend/src/index.ts b/shared-frontend/src/index.ts index 51f928c..60d89e1 100644 --- a/shared-frontend/src/index.ts +++ b/shared-frontend/src/index.ts @@ -72,6 +72,7 @@ export { useProjectShortcuts } from './composables/useProjectShortcuts' export type { ToolbarAction, ToolbarMenuDefinition } from './types/toolbar' export { default as ModalShell } from './components/ModalShell.vue' +export { default as PathField } from './components/PathField.vue' export { vModal } from './directives/modal' export { RELEASE_HIGHLIGHTS } from './releaseNotes' diff --git a/shared-frontend/src/releaseNotes.ts b/shared-frontend/src/releaseNotes.ts index 2811a16..ec47b6a 100644 --- a/shared-frontend/src/releaseNotes.ts +++ b/shared-frontend/src/releaseNotes.ts @@ -1,12 +1,12 @@ export const RELEASE_HIGHLIGHTS = { "zh-CN": [ - "主站浅色主题统一为白色数据区、浅灰侧栏和清晰的深色文字,连接树、值解析与弹窗不再混用深色样式。", - "寄存器和线圈写入遵循扫描组指定的从站 ID,并显示实际写入目标;未指定时继续使用连接默认 ID。", - "多字解析和写入要求同类型、连续地址的寄存器选择,避免将不连续地址误写为连续范围。" + "证书路径字段优先显示文件名,悬停查看完整路径,点击即可编辑。", + "macOS 文件选择框改为可自由拖动的独立窗口。", + "子站支持 PEM 格式的 RSA 私钥,TLS 启动失败会明确报错。" ], "en-US": [ - "Master light mode now uses white data surfaces, light gray sidebars and readable dark text consistently across the connection tree, value panel and dialogs.", - "Register and coil writes honor the scan group's unit ID and show the write target, while retaining the connection default when no override is set.", - "Multiword interpretation and writes require same-type, contiguous register selections, preventing sparse selections from being written as a packed range." + "Certificate path fields lead with the file name; hover for the full path, click to edit.", + "The macOS file picker is now a free-standing window that can be dragged anywhere.", + "Slave accepts PEM-encoded RSA keys and reports TLS startup failures clearly." ] } as const diff --git a/shared-frontend/tests/pathField.spec.ts b/shared-frontend/tests/pathField.spec.ts new file mode 100644 index 0000000..67f3e67 --- /dev/null +++ b/shared-frontend/tests/pathField.spec.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { defineComponent, h, ref, type Ref } from 'vue' +import { mount, type VueWrapper } from '@vue/test-utils' +import PathField from '../src/components/PathField.vue' + +const LONG_PATH = '/Users/daichangyu/Library/Mobile Documents/com~apple~CloudDocs/certs/server.pem' + +// Mirrors how the dialogs bind the field with v-model. +let wrapper: VueWrapper | undefined +let value: Ref + +afterEach(() => { + wrapper?.unmount() + wrapper = undefined +}) + +function mountField(initial: string) { + value = ref(initial) + const Host = defineComponent({ + setup: () => () => h(PathField, { + modelValue: value.value, + placeholder: '选择证书', + 'onUpdate:modelValue': (next: string) => { value.value = next }, + }), + }) + wrapper = mount(Host) + return wrapper +} + +describe('PathField', () => { + it('shows the end of a long path and keeps the full path in the tooltip', () => { + mountField(LONG_PATH) + const display = wrapper!.get('.path-display') + expect(display.text()).toBe('…/certs/server.pem') + expect(display.attributes('title')).toBe(LONG_PATH) + }) + + it('keeps short paths intact and shows the placeholder when empty', async () => { + mountField('/etc/ssl/cert.pem') + expect(wrapper!.get('.path-display').text()).toBe('/etc/ssl/cert.pem') + value.value = '' + await wrapper!.vm.$nextTick() + const display = wrapper!.get('.path-display') + expect(display.text()).toBe('选择证书') + expect(display.classes()).toContain('is-empty') + }) + + it('opens the full path for editing and commits the trimmed value on Enter', async () => { + mountField(LONG_PATH) + await wrapper!.get('.path-display').trigger('click') + const input = wrapper!.get('.path-input') + expect(input.element.value).toBe(LONG_PATH) + await input.setValue(' /etc/ssl/client.pem ') + await input.trigger('keydown', { key: 'Enter' }) + expect(value.value).toBe('/etc/ssl/client.pem') + expect(wrapper!.get('.path-display').text()).toBe('/etc/ssl/client.pem') + }) + + it('commits on blur and discards the draft on Escape', async () => { + mountField('/etc/ssl/a.pem') + await wrapper!.get('.path-display').trigger('click') + await wrapper!.get('.path-input').setValue('/etc/ssl/b.pem') + await wrapper!.get('.path-input').trigger('blur') + expect(value.value).toBe('/etc/ssl/b.pem') + + await wrapper!.get('.path-display').trigger('click') + await wrapper!.get('.path-input').setValue('/etc/ssl/c.pem') + await wrapper!.get('.path-input').trigger('keydown', { key: 'Escape' }) + expect(value.value).toBe('/etc/ssl/b.pem') + expect(wrapper!.get('.path-display').text()).toBe('/etc/ssl/b.pem') + }) + + it('emits browse when the browse button is pressed', async () => { + mountField('') + await wrapper!.get('.path-browse').trigger('click') + expect(wrapper!.findComponent(PathField).emitted('browse')).toHaveLength(1) + }) +})