| Thành phần | Công nghệ | Vị trí | Vai trò |
|---|---|---|---|
| Frontend (Client) | Vue 3 + Vite 8 (rolldown) | src/ |
SPA chạy trên browser, host độc lập hoặc do daemon phục vụ |
| Backend (Agent/Daemon) | Go 1.25 | src_backend/ |
3 module: cấu hình, lõi dịch vụ, ứng dụng |
| WASM | Rust / .NET 10 | src_wasm/ |
Remote Desktop, .NET Wrapper, PhotoCraft |
Frontend không nhúng "trái tim" logic nặng; những việc cần máy nội bộ (SQLite, proxy RDP, đọc/ghi file, mock server, PostgreSQL) đều đi qua agent API HTTP ở cổng 7777 (mặc định). Web UI của daemon là cổng 1403, mock server là cổng 8888.
src/main.js → createApp(App); đăng ký 2 directive, global objects ($tmCache, $tmEnum, $tmUtility, $tmEventBus), ~17 component global, i18n, toast, context-menu; khởi tạo Monaco workers; await loadLocaleDefault() rồi mount.
src/App.vue bố trí: TMHeaderApp / TMSidebar / TMDynamicTabView / TMFooterApp + hiệu ứng. created() gọi TMAppStartup.initialize(): nạp user settings, set window.__tmAPI.automation.agentURL, áp theme, đóng băng window.__env, set title.
src/stores/TMTabManager.js:{tabs[], activeTabId};openTab({titleKey, helpKey, groupKey, toolKey, component, checkExisting})lazy-load component; tabId =ComponentName-<guid>. Mỗi tab lưu thêm cờ layout đã resolve từ config (contentFlush,confirmOnClose) bằng helper nội bộgetToolContentLayout(groupKey, toolKey).src/views/misc/TMDynamicTabView.vue: thanh tab (drag-drop, close giữa chuột, duplicate, zen mode) + render tool active..tm-tab-contentcó padding mặc định, thêm classtm-tab-content-flushkhi tab active khai báocontentFlush; mọi thao tác đóng tab (nút X, chuột giữa, context menu, Alt+Q, đóng tất cả) đi quaonCloseTab/onCloseTabs/onExitTabMode→TMDialogUtil.confirm()nếu tab cần xác nhận.src/stores/TMToolConfigs.js: mảngsidebarConfiglà nguồn duy nhất đăng ký tool (chỉ chứa cấu hình, không xử lý logic). Kiểu:route,group(flyout nhiều con),automation. Helper đọc cấu hình:getSidebarItems(),getGroupConfig(groupKey),getAllSearchableRoutes().
TMHttpClient (fetch) → TMBaseAPI (URL = baseUrl + controller + endpoint; generic CRUD getAll/create/update/deleteById; getTree) → các class src/common/api/request/AgentAPI/*:
TMAgentAPI—getBaseUrl()đọcwindow.__tmAPI?.automation?.agentURL;heathCheck()(GET/).TMServerTestingAPI— test API (exec, parallel, import batch, file ops).TMServerMockAPI,TMServerPostgreSQLAPI,TMServerRDPAPI,TMServerAppDataMiner,TMTerminalAPI.
window.__env(từsrc/cfg/config.js): appName, theme mặc định,githubSource.url/releasesUrl,APITesting.agentServer(defaulthttp://localhost:7777).window.__tmAPI(từsrc/common/automation/TMAutomation.js, set trongTMAppStartup):automation.agentURL+ các method inject (parseCURL, http helpers…);dotnetExports(.NET WASM, từTMDotNetWasmMixin).
Main thread chỉ lo render + I/O. Mọi thứ block lâu (>~200ms) đều đẩy sang worker, để user vẫn click/switch tab được trong lúc xử lý.
TM*.vue ──run({type,payload})──▶ TMWorkerPool ──▶ Worker (module scope)
▲ │ │
└── resolve/reject ◀── {id,final,ok} ─┴── progress ─────────┘
src/common/worker/TMWorkerPool.js— pool generic, dùng chung quagetWorkerPool(name, createWorker, {maxWorker}). Mỗi pool tối đamin(4, hardwareConcurrency-1)worker; task được gán cho worker rảnh sẵn (first-available), hết chỗ thì spawn thêm đến khi đạt trần. Tựterminate()toàn bộ sau 60s không rảnh (giải phóng RAM cho app nhiều tab). Worker chết giữa chừng → task đang chạy reject, worker hỏng bị loại và tạo lại ở task kế tiếp.- Worker file — mỗi miền nặng một file riêng, không import gì chạm DOM.
- Protocol (chuẩn hoá cho mọi worker):
main → worker {id, type, payload};worker → main {id, final:true, ok, result|error}kèm{id, final:false, type:"<progress>"}cho báo tiến trình. Task được xếp chuỗi promise trong worker nên 1 worker chạy tuần tự 1 task — đúng giả định của pool. - Ràng buộc kỹ thuật:
- Worker không có DOM →
createImageBitmap(blob)thayImage/FileReader,OffscreenCanvas(+convertToBlob) thay canvas DOM,ImageData/Uint8ClampedArraythì có sẵn. new Worker(new URL("./x.js", import.meta.url), {type:"module"})phải literal trong module gọi — Vite chỉ transform đúng pattern lồng nhau này (pluginvite:worker-import-meta-url).- Payload phải structured-clone được: không truyền reactive Proxy của Vue, function, DOM node. Hằng số config đặt ngoài component hoặc
markRaw(). - Worker được bundle riêng nên không đi qua
build.rolldownOptions.output;vite.config.jskhai báo thêmworker.rollupOptions.outputđể giữ prefixtjs-/tas-+ version chống cache CDN.
- Worker không có DOM →
Mẫu tham chiếu — QR (src/common/qrcode/):
| File | Vai trò |
|---|---|
TMQRCodeConfig.js |
QR_CONFIG dùng chung main thread ↔ worker (định dạng ảnh hỗ trợ, giới hạn canvas, bộ tiền xử lý) |
TMQRCodeWorker.js |
task gen (ma trận QR → OffscreenCanvas → PNG → data URL) và task scan (decode ảnh → tiền xử lý pixel → jsQR, nhiều tỉ lệ + nhiều bộ lọc) |
TMQRCodeWorkerClient.js |
sở hữu pool, generateQRItems() / scanQRFiles() |
TMQRCodeUtil.js |
orchestrator main thread: imagesQRToText() chạy song song qua pool, fallback QrScanner/ZXing ở main thread (cần DOM) khi worker không đọc được |
Sinh QR dùng core thuần của thư viện qrcode (qrcode/lib/core/qrcode.js + lib/renderer/utils.js) vì bản toDataURL cần canvas DOM; getOptions/qrToImageData được tái dùng để giữ nguyên output cũ.
Thứ tự tìm kiếm trong scan được sắp theo chi phí tăng dần, vì jsQR chỉ ~1.2 Mpx/s: kích thước gốc trước (bỏ 3 bộ lọc thừa vì attemptBoth đã thử cả ảnh đảo màu), rồi mới thu nhỏ 0.5/0.4/0.3 (loại trùng kích thước), cuối cùng mới xoay 90°/270° ở kích thước đầu. Ảnh 2048² không có QR: worker mất ~13s nhưng main thread vẫn phản hồi đều.
Cả 2 tool đều báo tiến trình (spinner + x/y) vì tác vụ vẫn mất vài giây, và dùng token (generateToken / scanToken) để kết quả của lần bấm trước không đè lên lần sau.
src/i18n/i18nData.js: reactive {locale, fallbackLocale:"vi", messages}, tích hợp qua app.use, $t/$te. Mỗi locale (en/, vi/) gồm 5 file: i18nCommon, i18nUserSettings, i18nHelp, i18nTemplate, i18nTip + global/i18nGlobal.js. loadLocale(locale) dynamic import; mặc định theo user setting currentLanguage.
- Dialog:
TMDialogUtil+TMDialogEnum(append-only) +DialogComponentMap→ mount Vue app riêng kế thừa appContext;confirm()promise; ESC/closeById/closeAll. - Toast:
TMToastPlugin→ singletontoast, dùngthis.$tmToast.success/error/warning/info(...).
| Module | Mô tả | Ghi chú |
|---|---|---|
tm_config |
TMCenterConfig + defaults, GetConfigGlobal() singleton (tìm config/config.json đi ngược từ exe; sinh file mặc định nếu thiếu), var Version (ldflags) |
module lá: không require module nào; được tm_core_service và tm_app tham chiếu qua replace tm_config => ../tm_config |
tm_core_service |
Thư viện lõi: external/api_app/tm_api_builder.go, external/web_app/tm_web_builder.go, internal/router/*, internal/service/*, internal/database/* (+ migrations/ 0001–0003), internal/middleware/tm_cors.go |
requires tm_config (replace); gorilla/websocket, pgx, modernc sqlite, lipgloss |
tm_app |
2 binary: cmd/api_app (chỉ API), cmd/daemon_app (chạy API app + Web app song song goroutine, gated bởi cfg.APIConfig.Enable/cfg.WebConfig.Enable) |
replace tm_core_service + tm_config |
SQLite: dev_tool.db cạnh exe, WAL, foreign_keys=ON, migration chạy lúc khởi động (internal/database/migrations/, idempotent).
| File | Endpoint |
|---|---|
rt_common.go |
GET /{$} — health check {success, message, beVersion} (BE version = tm_config.Version; {$} = khớp chính xác root, Go 1.22+) |
rt_api_testing.go |
POST /api_test/exec, /exec_parallel, /import_batch, /import_pro_mode_batch; CRUD + get_tree cho collections |
rt_mock_api.go |
CRUD + get_tree mock_api/mock_group; GET /mock_api/restart_mock_server, /get_base_url; POST /import_batch |
rt_rdp.go |
GET /rdp/ws — WebSocket proxy RDP (RDCleanPath: parse ASN.1 DER, TCP→X.224→TLS, relay 2 chiều) |
rt_rdp_connection.go |
CRUD + get_tree rdp_connection/rdp_connection_group |
rt_ultraviewer.go |
GET /ultraviewer/ws — WebSocket proxy tới relay UltraViewer (daemon mở TCP + TLS có ghim chứng thư rồi chuyển byte 2 chiều) |
rt_postgresql.go |
POST /postgresql/execute_query, /database_ops, /database_ops_upload; GET /detect_bin_path; CRUD + get_tree |
rt_file_ops.go |
POST /file_ops/read_file, /read_folder, /write_file |
rt_app_data_miner.go |
GET /data_miner/get_all_table_and_columns; POST /execute_query (SQLite introspection) |
Middleware: CORS Allow-Origin * + OPTIONS short-circuit; path lowercasing mặc định (endpoint_case_sensitive: false); route kiểu Go 1.22+ method-pattern (app.HandleFunc("POST /x", …)).
internal/model/tm_base_model.go—TMBaseModel(id, created_date, modified_date); model implement interfaceTMModelBase { TableName(); PrimaryKey() }.internal/database/dl_base.go—TMDLBase[T]: repository reflection theojsontags, auto UUID PK, batch, raw query/exec.internal/service/bl_base.go—TMBLBase[T]: controller HTTP generic đăng kýGET get_all,POST create,PUT update,DELETE delete_by_id; hookBeforeInsert/AfterInsert/…/CustomDelete/CustomCreate.internal/service/bl_collection.go—TMCollection[TGroup,TItem]: master-detail, đăng ký cả 2 +GET get_tree; xoá group cascade qua transaction. Dùng bởi 5 tool (api testing, pro-mode, mock api, rdp connection, postgresql connection).
internal/web/bl_web.go: //go:embed all:dist → phục vụ SPA (fallback index.html), force MIME application/wasm cho *.wasm, auto-open browser sau 500 ms khi có web_config.enable.
| Module | Build | Output (gitignored) | Load bởi |
|---|---|---|---|
| IronRDP | wasm-pack build --target web |
pkg/rdp/rdp_client.js + _bg.wasm (~4,5 MB) |
TMRemoteDesktopRDP.vue |
| UltraViewer | wasm-pack build --target web |
pkg/ultraviewer/ultraviewer_client.js + _bg.wasm (~0,4 MB) |
TMRemoteDesktopUltraViewer.vue |
| .NET Wrapper | dotnet publish … AppBundle/_framework |
pkg/dotnet/ (~3,9 MB) |
TMDotNetWasmMixin.js |
| PhotoCraft | trunk build --release + nén wasm gzip |
pkg/photocraft/ (manifest.json + js + _bg.wasm.gz) |
views/tools/Craft/TMPhotoCraft.vue (canvas) |
Tool "craft" dùng chung component src/views/tools/Craft/TMCraftApp.vue: mỗi tool chỉ là wrapper mỏng truyền app-key (photocraft). Component không nạp index.html trong iframe — nó render thẳng một <canvas> và tự boot wasm:
fetch(/assets-wasm-<version>/<appKey>/manifest.json)→ tên file glue + wasm (có content-hash, đổi theo từng build);fetch(.../<wasm>.gz)→ giải nén ngay trong JS bằngDecompressionStream("gzip");import(<glue>.js?i=<instanceKey>)→ gọiinit({ module_or_path: wasmBytes }).
Nhờ query ?i= riêng cho từng tab, mỗi tab có instance wasm riêng (wasm-bindgen chỉ khởi tạo một lần cho mỗi module). Rust main() tra canvas bằng document.getElementById("photocraft_canvas") — id cố định, không truyền được từ JS — nên src/common/TMCraftCanvas.js shim getElementById để trả đúng <canvas> của tab đó (xem mục "Vì sao phải là singleton" trong file). Nhờ vậy canvas nằm nguyên trong DOM của tab nên đổi tab không reload app.
index.html do trunk sinh ra vẫn được giữ lại: nó là trang chạy độc lập để debug app wasm, và là nơi patch_wasm_dist.py ghi logic tự giải nén.
vite.config.js (viteStaticCopy) quét động mọi folder con trong src_wasm/pkg/ rồi copy pkg/<tên>/**/* → assets-wasm-<VERSION>/<tên>/, giữ nguyên cấu trúc thư mục con — thêm wasm mới không phải sửa vite.config.js. Giữ cấu trúc là bắt buộc: wasm-bindgen sinh snippets/<crate>-<hash>/inline0.js và file glue import tương đối tới đó (import … from './snippets/eframe-<hash>/inline0.js'); nếu làm phẳng thì file không tồn tại trên server, Pages trả index.html (text/html) và app chết với Failed to load module script + Failed to fetch dynamically imported module. scripts/deploy_cloudflare.sh chạy scripts/check_wasm_dist.py để chặn đúng lỗi này trước khi deploy. Runtime nạp theo đường dẫn tuyệt đối assets-wasm-<version>/… (DEV cũng được serve bởi plugin static-copy middleware). Alias @wasm → src_wasm (dùng cho dotnet khi dev).
pkg/ là thư mục output, gitignored (~58 MB binary). Vì repo không chứa wasm, vite.config.js bỏ qua folder pkg/<tên> nào rỗng (chỉ cảnh báo) để npm run build vẫn xanh trên clone mới; khi build phát hành (REQUIRE_WASM=1) thì pkg/ trống hoặc folder rỗng là fail ngay. Vì vậy Cloudflare Pages không tự build được → deploy thủ công qua scripts/deploy_cloudflare.sh.
Tool Remote Desktop (UltraViewer) xem/điều khiển máy khác đang chạy UltraViewer (Windows).
Giao thức UltraViewer được port từ dự án MacViewer của
DeanOpen (giấy phép MIT) — xem src_wasm/ultraviewer/README.md để biết file nào port từ đâu.
Trình duyệt không mở được socket thô (không có TLS client trong WASM) nên chia 2 tầng:
[trình duyệt] src_wasm/ultraviewer ──WebSocket──▶ daemon Go ──TCP+TLS──▶ relay UltraViewer
giao thức byte thô trung gian (functions3.ultraviewer.net)
| Tầng | Nhiệm vụ |
|---|---|
src_wasm/ultraviewer (Rust → wasm-pack) |
Toàn bộ giao thức: mã hoá 3DES kênh lệnh, envelope relay, giải nén zstd (kèm từ điển 8bit.dat), dựng hình + theo dõi vùng đổi, mã hoá chuột/bàn phím |
internal/service/bl_ultraviewer_relay.go (Go) |
Mở TCP + TLS tới relay (ghim chứng thư Sectigo R46, SNI ultraviewer), chuyển byte hai chiều qua WebSocket |
Giao thức WebSocket giữa WASM và daemon: message text (JSON) để mở/đóng kênh, message binary (byte đầu = số kênh: 1 = kênh lệnh, 2 = kênh dữ liệu với máy đối tác, 3 = kênh LAN trực tiếp) để chuyển byte thô.
Đường LAN trực tiếp (kênh 3): khi 2 máy cùng mạng LAN, máy đối tác tự kết nối thẳng vào listener của daemon (bỏ qua relay → độ trễ thấp hơn nhiều, màn hình không rời LAN).
[trình duyệt] src_wasm/ultraviewer ──WebSocket──▶ daemon Go ◀──TCP (LAN, AES-256-CBC)── máy đối tác
internal/service/bl_ultraviewer_direct.go(Go): mở listener trên IP LAN, sinh khoá phiên 14 ký tự, mã hoá/giải mã khungLE32(len) + IV(16) + AES-256-CBC/PKCS7(flag + payload). Phần mã hoá nằm ở daemon nên phía WASM chỉ thấy byte thô.- Module WASM quảng bá listener trong lệnh tra cứu (
IPRelay|PortRelay|IPLAN|PortLAN|key), chuyển đường chính sang kênh 3 khi màn hình khởi tạo đến qua kênh đó, và tự quay lại relay nếu kênh trực tiếp hỏng hoặc máy đối tác không cùng LAN.
Phần giao thức Rust thuần nên test được native (cargo test trong src_wasm/ultraviewer) —
trong đó có test end-to-end với relay giả lập: đăng ký → tra cứu → login → nhận màn hình →
gửi chuột/bàn phím, và test đường LAN trực tiếp (chuyển đường + fallback).
Phía Go có test end-to-end kênh LAN trực tiếp (bl_ultraviewer_direct_test.go): listener →
kết nối máy đối tác giả lập → mã hoá/giải mã hai chiều, đối chiếu vector với openssl.
VERSION=1.2.3 ./build_all.sh
├─ scripts/tm_version.sh → version + ldflags
├─ scripts/build_wasm.sh → (tùy chọn, đang comment) build IronRDP + .NET + 2 craft tool
├─ scripts/build_web_for_daemon.sh → npm install && npm run build
│ └─ copy dist/ → src_backend/tm_core_service/internal/web/dist/ (go:embed)
├─ scripts/build_api.sh → out/dev-tool-api-{mac-arm|linux|window}
└─ scripts/build_daemon.sh → out/dev-tool-{mac-arm|linux|window}-<VERSION> (nhúng frontend)
VERSION: VERSION env > package.json.version; vite.config.js đặt import.meta.env.PACKAGE_VERSION; tm_version.sh tạo -X tm_config.Version=…. UI version và BE version luôn khớp, frontend tự so sánh qua health check (hiện cảnh báo "agent not found"/version lệch).
./scripts/deploy_cloudflare.sh [--build-wasm] [--preview]
├─ scripts/build_wasm.sh → (tùy chọn) build lại wasm vào src_wasm/pkg/
├─ npm install
├─ REQUIRE_WASM=1 npm run build
└─ npx wrangler pages deploy dist --project-name devtools (wrangler.jsonc)
Pages project: devtools. Không bật Git integration — Pages chỉ checkout code sẽ không có src_wasm/pkg/ nên bản deploy hỏng Remote Desktop / .NET Wrapper / 4 tool Craft.
Tool (TM*.vue) → new TMServerXxxAPI() // không truyền baseUrl
→ TMAgentAPI.getBaseUrl() // window.__tmAPI.automation.agentURL
→ TMHttpClient (fetch) → POST http://<agentURL>/<controller>/<action>
→ CORS allow * → router rt_*.go → service/bl_*.go
→ database/dl_*.go (SQLite) → { success, message, data }
Web-only (không daemon): tool cần agent sẽ báo "agent not found"; header có link "Download agent" trỏ GitHub Releases.
vite.main.config.mjs,vite.preload.config.mjs,package.json "main": ".vite/build/main.js"là tàn dư thời Electron (nhánh backupfeature/electronjs_backup,feature/tauri_rust_backup). Hiện tại không còn desktop shell.- Không có CI workflow (chưa có
.github/).