SATF: Kéo file, lắc nhẹ là đổi định dạng — mình đã làm nó cho cả macOS và Windows như thế nào
SATF: kéo file, lắc nhẹ là đổi định dạng — và những thứ mình học được khi làm nó cho cả macOS lẫn Windows

Chuyện bắt đầu từ một cú Export
Mỗi ngày mình đổi file không biết bao nhiêu lần: ảnh HEIC từ iPhone sang JPG, PNG sang WebP cho web, file Word sang PDF để gửi đi, giải nén một file zip… Lần nào cũng vậy: mở app → File → Export → chọn định dạng → chọn thư mục → Save.
Mình muốn một cách nhanh hơn: đang kéo file thì lắc nhẹ, một menu hiện ra ngay dưới con trỏ, thả vào định dạng muốn đổi là xong. File mới nằm cạnh file gốc, không mở thêm cửa sổ nào.
Thế là có SATF, app nhỏ chạy trên thanh menu macOS và khay hệ thống Windows, miễn phí và mã nguồn mở.
👉 Tải về: https://github.com/tran-minh-khoi/SATF/releases/latest 👉 Mã nguồn: https://github.com/tran-minh-khoi/SATF
SATF làm được gì

- Nửa phải là Chuyển đổi: định dạng hay dùng đứng trước. Nhiều hơn 6 lựa chọn thì ô cuối là "Thêm", rê vào là bung cả vòng tròn.
- Nửa trái là Copy (dưới) và Khay (trên): khay là một ô nổi để gom file từ nhiều nơi rồi kéo ra dùng một lần.
- Ảnh: JPG, PNG, PDF, WebP, HEIC, GIF, TIFF, BMP, AVIF, ICO
- PDF sang ảnh (mỗi trang một ảnh), Word/Excel/PowerPoint sang PDF
- Video, nhạc: MP4, MOV, M4A, WAV, MP3…
- Giải nén zip, rar, 7z, tar, gz…; nén ZIP mọi thứ
- Tiếng Anh / Tiếng Việt, mọi thứ chạy trên máy, không gửi file đi đâu
Phần còn lại của bài là những thứ mình thấy thú vị khi làm.
1. Bắt cử chỉ "lắc" mà không cần quyền Accessibility
Trên macOS, app muốn theo dõi bàn phím toàn hệ thống thì phải xin quyền Accessibility, và người dùng rất ngại bấm cho phép. May là theo dõi sự kiện chuột thì không cần quyền đó.
Ý tưởng:
- Nghe sự kiện nhấn chuột trái ở mọi app bằng
NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown). - Khi chuột đang giữ, lấy mẫu vị trí con trỏ khoảng 60 lần/giây.
- Đếm số lần đổi chiều theo trục ngang. Mỗi nhịp phải đi đủ xa (25–50 point tuỳ độ nhạy), và đủ 3–5 lần đổi chiều trong vòng 0,9 giây thì tính là một cái lắc.
if CGFloat(direction) * dx > 0 {
extremeX = x // vẫn đi tiếp cùng chiều: cập nhật điểm xa nhất
} else if abs(dx) >= minSwing { // đổi chiều đủ xa: tính là một nhịp lắc
direction = -direction
extremeX = x
reversals.append(now)
reversals.removeAll { now - $0 > 0.9 }
if reversals.count >= neededReversals { onShake() }
}
Làm sao biết người dùng đang kéo file chứ không phải kéo cửa sổ hay bôi đen chữ? Khi bắt đầu kéo file, Finder ghi danh sách file vào một pasteboard riêng tên là drag pasteboard. Lúc nhấn chuột, mình lưu lại changeCount của nó. Khi phát hiện lắc, nếu changeCount đã đổi và đọc ra được URL file thì chắc chắn đang kéo file.
Trên Windows, mình dùng low-level mouse hook (SetWindowsHookEx(WH_MOUSE_LL, …)) với đúng thuật toán đó. Windows không cho đọc dữ liệu kéo-thả từ bên ngoài, nên menu tròn được bật AllowDrop và đọc danh sách file ngay khi con trỏ (đang cầm file) đi vào menu, ở sự kiện DragEnter.
2. Menu tròn chỉ là… toạ độ cực
Menu là hai vòng tròn đồng tâm. Mọi thao tác rê chuột quy về hai con số: khoảng cách tới tâm và góc.
let r = hypot(dx, dy)
var angle = atan2(dy, dx) * 180 / .pi
if angle < 0 { angle += 360 }
// 90°–180°: Copy · 180°–270°: Khay · 270°–450°: Chuyển đổi
rnằm trong vòng trong thì chọn một nửa, đồng thời bung vòng ngoài.rnằm ở vòng ngoài thì chia góc cho số ô để biết đang trỏ vào định dạng nào.
Trên Mac, phần vẽ là SwiftUI: mỗi ô là một Shape hình vành khuyên, có hiệu ứng spring khi rê vào. Phần nhận chuột và kéo-thả là một NSView trong suốt đè lên trên, vì cửa sổ menu là loại "không giành focus" (nonactivatingPanel) để không làm gián đoạn app người dùng đang dùng. Trên Windows, mình vẽ lại y hệt bằng PathGeometry của WPF.
Một chi tiết nhỏ: khi đang kéo file, app khác không nhận được sự kiện bàn phím. Vậy bấm Esc để huỷ thế nào? Trên Mac, mình đăng ký Esc làm phím tắt toàn cục bằng Carbon RegisterEventHotKey, chỉ trong lúc menu đang mở, đóng menu là gỡ ngay. Trên Windows thì kiểm tra GetAsyncKeyState(VK_ESCAPE) mỗi 100ms.
3. Đổi định dạng bằng "đồ có sẵn" của hệ điều hành
Mình cố gắng không kéo thêm thư viện nếu hệ điều hành đã làm được.
macOS:
- Ảnh:
ImageIO(CGImageDestination). Nó ghi được JPG, PNG, HEIC, AVIF, GIF, TIFF, BMP, ICO… trừ WebP. Vì vậy mình đóng gói kèmcwebpcủa Google (khoảng 1 MB). - PDF sang ảnh:
PDFKitvẽ từng trang ở 144 DPI. - Word sang PDF:
NSAttributedStringđọc được.docx, rồi "in" ra PDF bằngNSPrintOperationmà không hiện hộp thoại in. - Video, nhạc:
AVAssetExportSession. Mình thử chế độ passthrough trước (không mã hoá lại, cực nhanh), không được mới mã hoá lại. WAV/AIFF thì dùngafconvertcó sẵn trong máy. - Giải nén:
dittocho zip,tar(bản bsdtar của macOS đọc được cả rar, 7z) cho phần còn lại.
Windows:
- Ảnh: WIC qua
BitmapEncodercủa WPF, riêng WebP thì dùng SkiaSharp. - PDF sang ảnh:
Windows.Data.Pdf, API có sẵn từ Windows 10. - Word/Excel/PowerPoint: nếu máy có Office thì điều khiển Office qua COM ở chế độ ẩn (
ExportAsFixedFormat), không có thì dùng LibreOffice chạy ngầm. - Giải nén:
tar.execó sẵn từ Windows 10.
Gói nặng chỉ tải khi cần
Excel/PowerPoint sang PDF mà muốn đẹp thì cần một "bộ máy Office" thật sự, ví dụ LibreOffice (~300 MB). Nhét vào app thì app nặng gấp 60 lần, cho tuỳ chọn thì không ai biết mà bật.
Cách mình chọn:
- Bộ cài Windows có trang tick chọn gói (mặc định không tick).
- Khi người dùng thả một file Excel mà máy chưa đủ điều kiện, SATF hiện hộp "Cần thêm một bước". Bấm tải là app tự lấy bản LibreOffice mới nhất từ trang chính thức (đọc danh sách phiên bản trên download.documentfoundation.org), cài vào thư mục riêng, rồi đổi luôn file vừa thả.
- Máy đã có Microsoft Office thì không hỏi gì cả.
- Có trang "Add-ons" để xem từng gói chiếm bao nhiêu dung lượng và xoá riêng, không phải gỡ cả app.
4. Những con bug đáng nhớ
App Windows văng ngay khi mở. Mình build bản Windows trên Mac nên không chạy thử được. May là đã kịp thêm phần ghi lỗi ra file satf.log. Log chỉ ra: WPF không đọc được file .ico chứa ảnh PNG bên trong, trong khi Windows Explorer thì đọc ngon. Đổi sang dùng PNG là xong. Bài học: luôn có log cho lần chạy đầu tiên trên máy người khác.
Lỗi chỉ xảy ra trên máy để định dạng số kiểu Việt Nam. Vòng xoay "đang xử lý" vẽ bằng chuỗi hình học "M 14,1.55 A …". Máy để vùng Việt Nam in số thập phân là 1,55, thế là chuỗi vỡ. Sửa bằng FormattableString.Invariant(...).
Menu hiện lệch chỗ trên màn hình phóng to 150%. Mouse hook trả về toạ độ pixel thật, còn WPF dùng đơn vị DIP. Phải chia cho VisualTreeHelper.GetDpi(...).
Script build Mac xoá luôn bản Windows. build.sh có dòng rm -rf build, mà bản Windows cũng nằm trong build/windows. Chạy build Mac sau build Windows là bay màu file exe 😅
5. Build cả hai bản trên một máy Mac
- macOS: chỉ cần Command Line Tools, không cần mở Xcode.
swiftcbuild hai lần choarm64vàx86_64, gộp bằnglipo, ký bằng Developer ID, gửi Apple kiểm tra bằngnotarytoolrồi gắn kết quả vào DMG bằngstapler. - Windows:
dotnet publish -r win-x64 --self-contained -p:PublishSingleFile=truechạy được ngay trên macOS. Bộ cài dùng NSIS (brew install makensis): có chọn ngôn ngữ, chọn gói tải thêm, và hiện trong Control Panel với đúng tên nhà phát triển. - Icon: script tự tạo
.icnsvà.icotừ một fileassets/logo.png. - Ảnh chụp trong README cũng được dựng thẳng từ code SwiftUI (render ra ảnh khi chạy ngầm), nên đổi giao diện là chạy một lệnh để có ảnh mới.
Thử và góp ý giúp mình nhé
Đây là bản 1.0.0 nên chắc chắn còn nhiều chỗ chưa ổn, nhất là bản Windows. Nếu anh em dùng thử:
- 🐞 Gặp lỗi: menu SATF → Báo lỗi…, hoặc tạo issue trên GitHub
- 💡 Có ý tưởng: menu SATF → Góp ý tính năng…
- ⭐ Thấy hữu ích: cho mình xin một sao trên GitHub
👉 https://github.com/tran-minh-khoi/SATF
Cảm ơn mọi người đã đọc tới đây! 🐾
All Rights Reserved