diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a956de8..4cbba46 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build Unity WakaTime +name: Build Creative WakaTime on: pull_request: @@ -72,8 +72,8 @@ jobs: shell: msys2 {0} run: | echo "โœ… Ninja build test completed!" - if [ -f "build/unity_wakatime.exe" ]; then - echo "๐Ÿ“ฆ Executable created: $(stat -c%s build/unity_wakatime.exe) bytes" + if [ -f "build/creative_wakatime.exe" ]; then + echo "๐Ÿ“ฆ Executable created: $(stat -c%s build/creative_wakatime.exe) bytes" echo "๐Ÿฅท Ninja build test passed - ready for production!" else echo "โŒ Ninja build test failed!" @@ -135,15 +135,15 @@ jobs: - name: Build Production Release (Ninja) shell: msys2 {0} run: | - echo "๐Ÿฅท Building Unity WakaTime with Ninja v${{ env.RELEASE_VERSION }}..." + echo "๐Ÿฅท Building Creative WakaTime with Ninja v${{ env.RELEASE_VERSION }}..." cmake --build build --config $BUILD_TYPE --parallel $(nproc) - name: Verify Production Build shell: msys2 {0} run: | - if [ -f "build/unity_wakatime.exe" ]; then + if [ -f "build/creative_wakatime.exe" ]; then echo "โœ… Ninja production build successful!" - echo "๐Ÿ“ฆ Final executable size: $(stat -c%s build/unity_wakatime.exe) bytes" + echo "๐Ÿ“ฆ Final executable size: $(stat -c%s build/creative_wakatime.exe) bytes" else echo "โŒ Ninja production build failed!" exit 1 @@ -151,20 +151,33 @@ jobs: - name: Create Release Package shell: msys2 {0} run: | - PACKAGE_NAME="Unity-Wakatime_v${{ env.RELEASE_VERSION }}" + PACKAGE_NAME="Creative-Wakatime_v${{ env.RELEASE_VERSION }}" + EXTENSION_NAME="creative-wakatime" echo "๐Ÿ“ฆ Creating release package: $PACKAGE_NAME" mkdir -p "release-package/$PACKAGE_NAME" - cp build/unity_wakatime.exe "release-package/$PACKAGE_NAME/" + cp build/creative_wakatime.exe "release-package/$PACKAGE_NAME/" cp build/logo_32.png "release-package/$PACKAGE_NAME/" [ -f README.md ] && cp README.md "release-package/$PACKAGE_NAME/" || echo "README.md not found" [ -f LICENSE ] && cp LICENSE "release-package/$PACKAGE_NAME/" || echo "LICENSE not found" + # Aseprite extension์€ ๋ณ„๋„ ์„ค์น˜ ๊ฐ€๋Šฅํ•œ .aseprite-extension(zip)์œผ๋กœ ํŒจํ‚ค์ง• + if [ -d aseprite-extension ]; then + mkdir -p release-package/aseprite-extension-build + cp aseprite-extension/package.json release-package/aseprite-extension-build/ + cp aseprite-extension/wakatime.lua release-package/aseprite-extension-build/ + (cd release-package/aseprite-extension-build && 7z a "../${EXTENSION_NAME}.aseprite-extension" package.json wakatime.lua) + else + echo "aseprite-extension not found" + fi + echo "๐Ÿ“‹ Release package contents:" ls -la "release-package/$PACKAGE_NAME/" + ls -lh release-package/*.aseprite-extension || true echo "PACKAGE_NAME=$PACKAGE_NAME" >> $GITHUB_ENV + echo "EXTENSION_NAME=$EXTENSION_NAME" >> $GITHUB_ENV - name: Create ZIP Archive shell: msys2 {0} @@ -180,7 +193,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ env.PACKAGE_NAME }} - path: release-package/${{ env.PACKAGE_NAME }}/ + path: | + release-package/${{ env.PACKAGE_NAME }}/ + release-package/${{ env.EXTENSION_NAME }}.aseprite-extension retention-days: 90 - name: Upload ZIP to Release @@ -188,4 +203,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - files: release-package/${{ env.PACKAGE_NAME }}.zip \ No newline at end of file + files: | + release-package/${{ env.PACKAGE_NAME }}.zip + release-package/${{ env.EXTENSION_NAME }}.aseprite-extension \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index ab50b2c..a722549 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.23) -project(unity_wakatime) +project(creative_wakatime) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -11,6 +11,7 @@ set(SOURCES src/process_monitor.cpp src/file_watcher.cpp src/wakatime_client.cpp + src/inbox_bridge.cpp src/tray_icon.cpp src/windows_dark_mode.cpp src/unity_focus_detector.cpp @@ -21,15 +22,16 @@ set(HEADERS include/process_monitor.h include/file_watcher.h include/wakatime_client.h + include/inbox_bridge.h include/tray_icon.h include/windows_dark_mode.h include/unity_focus_detector.h ) -add_executable(unity_wakatime ${SOURCES} ${HEADERS}) +add_executable(creative_wakatime ${SOURCES} ${HEADERS}) if(WIN32) - target_compile_definitions(unity_wakatime PRIVATE + target_compile_definitions(creative_wakatime PRIVATE WINVER=0x0601 _WIN32_WINNT=0x0601 UNICODE=1 @@ -38,7 +40,7 @@ if(WIN32) ) if(MINGW) - target_link_options(unity_wakatime PRIVATE + target_link_options(creative_wakatime PRIVATE -static-libgcc -static-libstdc++ -static @@ -46,7 +48,7 @@ if(WIN32) -Wl,--enable-stdcall-fixup ) - target_link_libraries(unity_wakatime + target_link_libraries(creative_wakatime winhttp shell32 psapi @@ -58,7 +60,7 @@ if(WIN32) ) else() # MSVC์šฉ ์„ค์ • - target_link_libraries(unity_wakatime + target_link_libraries(creative_wakatime winhttp shell32 psapi @@ -74,23 +76,22 @@ if(WIN32) if(CMAKE_BUILD_TYPE STREQUAL "Release") if(MSVC) # MSVC: Windows ์„œ๋ธŒ์‹œ์Šคํ…œ ์„ค์ • - set_property(TARGET unity_wakatime PROPERTY WIN32_EXECUTABLE TRUE) - target_link_options(unity_wakatime PRIVATE + set_property(TARGET creative_wakatime PROPERTY WIN32_EXECUTABLE TRUE) + target_link_options(creative_wakatime PRIVATE /SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup ) elseif(MINGW) - # MinGW: ํฌ๊ธฐ ์ตœ์ ํ™” (-Os), LTO, ๋ฏธ์‚ฌ์šฉ ์„น์…˜ ์ œ๊ฑฐ (Resident Set Size ์ตœ์†Œํ™”) - target_compile_options(unity_wakatime PRIVATE + # MinGW: ํฌ๊ธฐ ์ตœ์ ํ™” (-Os), ๋ฏธ์‚ฌ์šฉ ์„น์…˜ ์ œ๊ฑฐ (Resident Set Size ์ตœ์†Œํ™”) + # GCC 16.1.0(MSYS2 MinGW)์—์„œ LTO(-flto) ๋งํฌ ์ค‘ ๋‚ด๋ถ€ ์ปดํŒŒ์ผ๋Ÿฌ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฏ€๋กœ ๋น„ํ™œ์„ฑํ™”ํ•œ๋‹ค. + target_compile_options(creative_wakatime PRIVATE -Os - -flto -ffunction-sections -fdata-sections ) - target_link_options(unity_wakatime PRIVATE - -Os # LTO ๋งํฌ ๋‹จ๊ณ„ ์ตœ์ ํ™”๋„ ํฌ๊ธฐ ์šฐ์„ ์œผ๋กœ + target_link_options(creative_wakatime PRIVATE + -Os # ๋งํฌ ๋‹จ๊ณ„๋„ ํฌ๊ธฐ ์šฐ์„ ์œผ๋กœ ์ตœ์ ํ™” -s # Strip symbols for smaller file size - -flto -Wl,--gc-sections ) endif() diff --git a/README.md b/README.md index 3690ba4..f6dcbde 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@
-## Unity - WakaTime +## Creative - WakaTime [![License: MIT](https://img.shields.io/badge/License-MIT-skyblue.svg?style=for-the-badge&logo=github)](LICENSE) -![GitHub Repo stars](https://img.shields.io/github/stars/snow0406/Unity-Wakatime?style=for-the-badge&logo=github&color=%23ef8d9d) +![GitHub Repo stars](https://img.shields.io/github/stars/snow0406/creative-wakatime?style=for-the-badge&logo=github&color=%23ef8d9d) -๐ŸŽฏ Automatic Unity project detection and time tracking for WakaTime +๐ŸŽฏ Automatic time tracking for **Unity** and **Aseprite** via WakaTime --- @@ -13,23 +13,25 @@ ### ๐Ÿš€ Features -- **Automatic Detection**: Finds Unity projects automatically -- **Real-time Monitoring**: Tracks file changes as you code +- **Unity**: Automatic project detection + real-time file change tracking +- **Aseprite**: Editing & saving activity tracking via a lightweight Lua extension - **Version Support**: Detects Unity editor versions (e.g., "Unity 2022.3") - **System Tray**: Runs quietly in background with right-click menu - **Smart Notifications**: Shows project start/stop alerts +- **Separate dashboards**: Unity and Aseprite are reported as distinct editors on WakaTime ### ๐Ÿ”ง System Requirements - **OS**: Windows - **API key**: WakaTime API KEY (free at wakatime.com) +- **Aseprite** (optional): for sprite/pixel-art tracking ### ๐Ÿ“ฆ Installation -1. Go to [Releases](https://github.com/Snow0406/Unity-Wakatime/releases) -2. Download the latest `Unity-Wakatime_vX.X.X.zip` +1. Go to [Releases](https://github.com/Snow0406/creative-wakatime/releases) +2. Download the latest `Creative-Wakatime_vX.X.X.zip` 3. Extract to your preferred location -4. Run `unity_wakatime.exe` +4. Run `creative_wakatime.exe` ### โš™๏ธ Setup @@ -37,16 +39,67 @@ - Visit https://wakatime.com/api-key - Copy your API key -2. **Configure Unity WakaTime**: +2. **Configure Creative WakaTime**: - Right-click the system tray icon - Click "๐Ÿ”‘ Setup API Key" - The WakaTime website will open automatically - Copy your API key and click OK - Wait for validation โœ… + - The API key is stored at `%APPDATA%/creative-wakatime/wakatime_config.txt` -3. **Start Unity and Code!** +3. **Unity** โ€” Start and Code! - Open any Unity project - - The app will automatically detect and start tracking + - The app automatically detects and starts tracking + +4. **Aseprite** โ€” Install the extension (optional): + - Download `creative-wakatime.aseprite-extension` from the same release. + - Double-click it, or install it from Aseprite via + **Edit > Preferences > Extensions > Add Extension**. + - Restart Aseprite. + - The extension writes local event files to `%APPDATA%/creative-wakatime/events/`. + - Creative WakaTime watches that folder and forwards heartbeats. + +### ๐Ÿงฉ How Aseprite tracking works + +Aseprite cannot be hooked directly from C++, so a small Aseprite **Lua extension** +emits local JSON event files on edit/save. The tray app watches +`%APPDATA%/creative-wakatime/events/` (event-driven, no polling) and converts each +event into a WakaTime heartbeat, then deletes the file. + +``` +Unity -> ProcessMonitor / FileWatcher -> WakaTimeClient -> WakaTime API +Aseprite -> Lua extension -> events/*.json -> Inbox bridge -> WakaTimeClient -> WakaTime API +``` + +Aseprite events are intentionally optional. If the extension is not installed, +Creative WakaTime only watches an empty local inbox and no Aseprite heartbeat is +created. + +#### Aseprite extension behavior + +- **Edit activity** (`sitechange`): active sprite/layer/frame changes are reported + as `is_write=false` with a 2-minute per-file debounce. +- **Save activity** (`aftercommand` โ†’ `SaveFile`/`SaveFileAs`/`SaveFileCopyAs`): + reported immediately as `is_write=true`. +- Unsaved sprites without a file path are ignored. +- `project` is the parent folder name of the sprite file. +- The extension does not call the WakaTime API directly; it only writes local + `.json` event files for the tray app to consume. + +#### Manual Aseprite extension install + +If you are building from source instead of using the release asset, zip the +contents of `aseprite-extension/` so that `package.json` and `wakatime.lua` are at +the root of the archive, then rename the archive to +`creative-wakatime.aseprite-extension`. + +You can also copy the folder manually: + +```text +%APPDATA%/Aseprite/extensions/creative-wakatime/ +``` + +The folder must contain both `package.json` and `wakatime.lua`. ## License @@ -54,4 +107,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file --- -Made with โ™ฅ by hy \ No newline at end of file +Made with โ™ฅ by hy diff --git a/aseprite-extension/package.json b/aseprite-extension/package.json new file mode 100644 index 0000000..e8b90dc --- /dev/null +++ b/aseprite-extension/package.json @@ -0,0 +1,14 @@ +{ + "name": "creative-wakatime", + "displayName": "Creative WakaTime", + "description": "Tracks Aseprite editing/saving activity for Creative WakaTime (WakaTime). Writes local event files consumed by the Creative WakaTime tray app.", + "version": "2.0.0", + "author": { + "name": "hy" + }, + "contributes": { + "scripts": [ + { "path": "./wakatime.lua" } + ] + } +} diff --git a/aseprite-extension/wakatime.lua b/aseprite-extension/wakatime.lua new file mode 100644 index 0000000..5a02a3d --- /dev/null +++ b/aseprite-extension/wakatime.lua @@ -0,0 +1,142 @@ +-- Creative WakaTime - Aseprite extension +-- +-- Aseprite์˜ ํŽธ์ง‘/์ €์žฅ ํ™œ๋™์„ ๊ฐ์ง€ํ•ด, WakaTime API๋กœ ์ง์ ‘ ๋ณด๋‚ด์ง€ ์•Š๊ณ  +-- ๋กœ์ปฌ ์ด๋ฒคํŠธ ํŒŒ์ผ(%APPDATA%/creative-wakatime/events/*.json)์„ ์ƒ์„ฑํ•œ๋‹ค. +-- Creative WakaTime ํŠธ๋ ˆ์ด ์•ฑ(C++)์ด ๊ทธ ํด๋”๋ฅผ ๊ฐ์‹œํ•ด heartbeat๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค. +-- +-- ์ด๋ฒคํŠธ: +-- sitechange -> ํ™œ์„ฑ sprite ์ „ํ™˜ ๋“ฑ ํŽธ์ง‘ ํ™œ๋™ (is_write = false) +-- aftercommand -> SaveFile / SaveFileAs / SaveFileCopyAs ์ดํ›„ (is_write = true) +-- +-- Aseprite Lua sandbox์—์„œ๋Š” rename์ด ์ œ๊ณต๋˜์ง€ ์•Š์œผ๋ฏ€๋กœ .json์— ์ง์ ‘ ์“ด๋‹ค. +-- C++ ์†Œ๋น„์ž๋Š” ๋นˆ/๋ฏธ์™„์„ฑ JSON์„ ์‚ญ์ œํ•˜์ง€ ์•Š๊ณ  ๋‹ค์Œ ์ด๋ฒคํŠธ๋‚˜ ์‹œ์ž‘ ์Šค์บ”์—์„œ ์žฌ์‹œ๋„ํ•œ๋‹ค. + +local EDIT_DEBOUNCE_SECONDS = 120 -- ๋™์ผ ํŒŒ์ผ ํŽธ์ง‘ heartbeat ์ตœ์†Œ ๊ฐ„๊ฒฉ(2๋ถ„) + +-- ์ €์žฅ ๋ช…๋ น์œผ๋กœ ๊ฐ„์ฃผํ•  command ์ด๋ฆ„ ์ง‘ํ•ฉ +local SAVE_COMMANDS = { + SaveFile = true, + SaveFileAs = true, + SaveFileCopyAs = true, +} + +local lastSentByEntity = {} -- entity -> ๋งˆ์ง€๋ง‰ ์ „์†ก os.time() +local writeCounter = 0 -- ๊ฐ™์€ ์ดˆ ๋‚ด ํŒŒ์ผ๋ช… ์ถฉ๋Œ ๋ฐฉ์ง€ +local listenerKeys = {} -- ํ•ด์ œํ•  ๋ฆฌ์Šค๋„ˆ ํ‚ค ๋ณด๊ด€ + +-- JSON ๋ฌธ์ž์—ด ๊ฐ’ ์ด์Šค์ผ€์ดํ”„ +local function jsonEscape(s) + s = s:gsub('\\', '\\\\') + s = s:gsub('"', '\\"') + s = s:gsub('\n', '\\n') + s = s:gsub('\r', '\\r') + s = s:gsub('\t', '\\t') + return s +end + +-- ์ด๋ฒคํŠธ ํด๋” ๊ฒฝ๋กœ ๋ฐ˜ํ™˜ (์—†์œผ๋ฉด ์ƒ์„ฑ). ์‹คํŒจ ์‹œ nil. +local function getEventsDir() + local appData = os.getenv("APPDATA") + if not appData or appData == "" then + return nil + end + + local dir = app.fs.joinPath(appData, "creative-wakatime", "events") + if not app.fs.isDirectory(dir) then + app.fs.makeAllDirectories(dir) + end + if not app.fs.isDirectory(dir) then + return nil + end + + return dir +end + +-- HeartbeatData ํ•œ ๊ฑด์„ ์ด๋ฒคํŠธ ํŒŒ์ผ๋กœ ๊ธฐ๋ก +local function writeEvent(entity, project, isWrite) + local dir = getEventsDir() + if not dir then + return + end + + -- ๊ฒฝ๋กœ๋ฅผ forward slash๋กœ ์ •๊ทœํ™” (๋Œ€์‹œ๋ณด๋“œ ํ‘œ๊ธฐ ์ผ๊ด€์„ฑ) + local normEntity = entity:gsub('\\', '/') + + writeCounter = writeCounter + 1 + local fname = "aseprite-" .. tostring(os.time()) .. "-" .. tostring(writeCounter) .. ".json" + local finalPath = app.fs.joinPath(dir, fname) + + local json = "{" + .. '"source":"aseprite",' + .. '"entity":"' .. jsonEscape(normEntity) .. '",' + .. '"project":"' .. jsonEscape(project) .. '",' + .. '"language":"Aseprite",' + .. '"editor":"Aseprite",' + .. '"is_write":' .. (isWrite and "true" or "false") .. ',' + .. '"time":' .. tostring(os.time()) + .. "}" + + local f = io.open(finalPath, "w") + if not f then + return + end + f:write(json) + f:close() +end + +-- ํ˜„์žฌ ํ™œ์„ฑ sprite์˜ ํŒŒ์ผ ๊ฒฝ๋กœ/ํ”„๋กœ์ ํŠธ๋ช…์„ ๊ตฌํ•ด ์ด๋ฒคํŠธ ๊ธฐ๋ก +local function emit(isWrite) + local sprite = app.sprite or app.activeSprite + if not sprite then + return -- ์—ด๋ฆฐ sprite ์—†์Œ + end + + local entity = sprite.filename + if not entity or entity == "" then + return -- ๋ฏธ์ €์žฅ ์ƒˆ ํŒŒ์ผ (filename ์—†์Œ) + end + + -- project = ํŒŒ์ผ์˜ ๋ถ€๋ชจ ํด๋” basename + local parentPath = app.fs.filePath(entity) + local project = app.fs.fileName(parentPath) + if not project or project == "" then + project = "Aseprite" + end + + local now = os.time() + + if not isWrite then + -- ํŽธ์ง‘ ํ™œ๋™: entity๋ณ„ 2๋ถ„ debounce (C++ ์ธก debounce์™€ ์ด์ค‘ ์•ˆ์ „) + local last = lastSentByEntity[entity] + if last and (now - last) < EDIT_DEBOUNCE_SECONDS then + return + end + end + + lastSentByEntity[entity] = now + writeEvent(entity, project, isWrite) +end + +local function onActivity() + emit(false) +end + +local function onAfterCommand(ev) + if ev and ev.name and SAVE_COMMANDS[ev.name] then + emit(true) + end +end + +function init(plugin) + -- ํ™œ์„ฑ sprite/layer/frame ์ „ํ™˜ = ํŽธ์ง‘ ํ™œ๋™ + listenerKeys[#listenerKeys + 1] = app.events:on('sitechange', onActivity) + -- ์ €์žฅ ๋ช…๋ น ์ดํ›„ + listenerKeys[#listenerKeys + 1] = app.events:on('aftercommand', onAfterCommand) +end + +function exit(plugin) + for _, key in ipairs(listenerKeys) do + app.events:off(key) + end + listenerKeys = {} +end diff --git a/include/file_watcher.h b/include/file_watcher.h index 7c90bed..9b4da07 100644 --- a/include/file_watcher.h +++ b/include/file_watcher.h @@ -9,11 +9,16 @@ */ class FileWatcher { private: + // ๊ฐ์‹œ ์ข…๋ฅ˜: Unity ํ”„๋กœ์ ํŠธ ํด๋”(์žฌ๊ท€) vs ์™ธ๋ถ€ ์ด๋ฒคํŠธ inbox ํด๋”(ํ‰๋ฉด) + enum class Kind { Unity, Inbox }; + // ๊ฐ์‹œ ์ค‘์ธ ํ”„๋กœ์ ํŠธ ์ •๋ณด struct WatchedProject { std::string projectPath; // ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ std::string projectName; // ํ”„๋กœ์ ํŠธ ์ด๋ฆ„ std::string unityVersion; // ์œ ๋‹ˆํ‹ฐ ๋ฒ„์ „ + Kind kind; // ๊ฐ์‹œ ์ข…๋ฅ˜ (๊ธฐ๋ณธ Unity) + BOOL recursive; // ํ•˜์œ„ ๋””๋ ‰ํ† ๋ฆฌ ํฌํ•จ ์—ฌ๋ถ€ (Unity: TRUE, Inbox: FALSE) HANDLE directoryHandle; // ๋””๋ ‰ํ† ๋ฆฌ ํ•ธ๋“ค std::thread watchThread; // ๊ฐ์‹œ ์Šค๋ ˆ๋“œ std::atomic shouldStop; // ์Šค๋ ˆ๋“œ ์ข…๋ฃŒ ํ”Œ๋ž˜๊ทธ @@ -23,6 +28,8 @@ class FileWatcher { HANDLE ioEvent; WatchedProject() : + kind(Kind::Unity), + recursive(TRUE), directoryHandle(INVALID_HANDLE_VALUE), shouldStop(false), stopEvent(nullptr), @@ -55,10 +62,14 @@ class FileWatcher { mutable std::mutex projectsMutex; // ์Šค๋ ˆ๋“œ ์•ˆ์ „์„ฑ์„ ์œ„ํ•œ ๋ฎคํ…์Šค std::deque pendingEvents; mutable std::mutex pendingEventsMutex; + std::deque pendingInboxFiles; // inbox์—์„œ ๊ฐ์ง€๋œ .json ๊ฒฝ๋กœ ํ + mutable std::mutex pendingInboxMutex; std::atomic notifyScheduled{false}; // PostMessage ์ฝ”์–ผ๋ ˆ์‹ฑ (ํ ์ ์žฌ ํ†ต์ง€ 1ํšŒ๋กœ ํ•ฉ์นจ) // ํŒŒ์ผ ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ์ฝœ๋ฐฑ ํ•จ์ˆ˜ std::function changeCallback; + // inbox์—์„œ .json ํŒŒ์ผ์ด ๊ฐ์ง€๋  ๋•Œ ํ˜ธ์ถœ (์ „์ฒด ๊ฒฝ๋กœ ์ „๋‹ฌ) + std::function inboxCallback; // ํ์— ์ด๋ฒคํŠธ๊ฐ€ ์ ์žฌ๋˜์—ˆ์Œ์„ ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์— ํ†ต์ง€ (PostMessage ๋“ฑ) std::function notifyCallback; @@ -106,7 +117,14 @@ class FileWatcher { * @param callback ํ†ต์ง€ ์‹œ ํ˜ธ์ถœ๋  ํ•จ์ˆ˜ (์˜ˆ: PostMessage) */ void SetNotifyCallback(std::function callback); - + + /** + * inbox ์ฝœ๋ฐฑ ์„ค์ • (์™ธ๋ถ€ ์ด๋ฒคํŠธ .json ํŒŒ์ผ์ด ๊ฐ์ง€๋  ๋•Œ ํ˜ธ์ถœ). + * ์ฝœ๋ฐฑ์€ ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์˜ DrainPendingEvents ํ๋ฆ„์—์„œ ๋””์ŠคํŒจ์น˜๋œ๋‹ค. + * @param callback .json ์ „์ฒด ๊ฒฝ๋กœ๋ฅผ ๋ฐ›๋Š” ํ•จ์ˆ˜ + */ + void SetInboxCallback(std::function callback); + /** * Unity ํ”„๋กœ์ ํŠธ ๊ฐ์‹œ ์‹œ์ž‘ * @param projectPath ๊ฐ์‹œํ•  ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ @@ -115,6 +133,14 @@ class FileWatcher { * @return ์„ฑ๊ณตํ•˜๋ฉด true */ bool StartWatching(const std::string& projectPath, const std::string& projectName, const std::string& unityVersion); + + /** + * ์™ธ๋ถ€ ์ด๋ฒคํŠธ inbox ํด๋” ๊ฐ์‹œ ์‹œ์ž‘ (ํ‰๋ฉด ๊ตฌ์กฐ, ๋น„์žฌ๊ท€). + * ํด๋” ๋‚ด .json ํŒŒ์ผ ์ƒ์„ฑ/์ด๋ฆ„๋ณ€๊ฒฝ/์ˆ˜์ • ์‹œ inboxCallback์ด ํ˜ธ์ถœ๋œ๋‹ค. + * @param inboxPath ๊ฐ์‹œํ•  inbox ํด๋” ๊ฒฝ๋กœ (์˜ˆ: %APPDATA%/creative-wakatime/events) + * @return ์„ฑ๊ณตํ•˜๋ฉด true + */ + bool StartWatchingInbox(const std::string& inboxPath); /** * ํŠน์ • ํ”„๋กœ์ ํŠธ ๊ฐ์‹œ ์ค‘์ง€ diff --git a/include/globals.h b/include/globals.h index 1f379ab..5aa7248 100644 --- a/include/globals.h +++ b/include/globals.h @@ -25,6 +25,8 @@ #include #include #include +#include +#include namespace fs = std::filesystem; @@ -98,7 +100,8 @@ namespace Config // WakaTime ์„ค์ • const std::string WAKATIME_API_URL = "https://api.wakatime.com/api/v1/users/current/heartbeats"; - const std::string USER_AGENT = "unity-wakatime/1.0"; + const std::string APP_NAME = "creative-wakatime"; + const std::string APP_VERSION = "2.0"; const int HEARTBEAT_TIMEOUT_MS = 5000; const int FILE_WATCHER_BUFFER_SIZE = 4096; const int HEARTBEAT_DEBOUNCE_MS = 2000; @@ -118,6 +121,70 @@ namespace Config static const auto folders = std::unordered_set(IGNORE_FOLDERS.begin(), IGNORE_FOLDERS.end()); return folders; } + + /** + * ์•ฑ ๋ฐ์ดํ„ฐ ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ ๋ฐ˜ํ™˜ (%APPDATA%/creative-wakatime/). + * ํด๋”๊ฐ€ ์—†์œผ๋ฉด events/ ํ•˜์œ„๊นŒ์ง€ ์ƒ์„ฑํ•œ๋‹ค. ์‹คํŒจ ์‹œ ๋นˆ ๋ฌธ์ž์—ด. + * @return ์•ฑ ๋ฐ์ดํ„ฐ ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ (๋์— ๊ตฌ๋ถ„์ž ์—†์Œ), ์‹คํŒจ ์‹œ "" + */ + inline std::string GetAppDataDir() + { + const char *appData = std::getenv("APPDATA"); + if (appData == nullptr || appData[0] == '\0') + { + return ""; + } + + const std::string dir = std::string(appData) + "\\" + APP_NAME; + + std::error_code ec; + fs::create_directories(dir, ec); // ์ด๋ฏธ ์กด์žฌํ•ด๋„ ์—๋Ÿฌ ์•„๋‹˜ + if (ec) + { + return ""; + } + + return dir; + } + + /** + * ์ด๋ฒคํŠธ inbox ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ ๋ฐ˜ํ™˜ (%APPDATA%/creative-wakatime/events/). + * ํด๋”๊ฐ€ ์—†์œผ๋ฉด ์ƒ์„ฑํ•œ๋‹ค. ์‹คํŒจ ์‹œ ๋นˆ ๋ฌธ์ž์—ด. + */ + inline std::string GetEventsDir() + { + const std::string base = GetAppDataDir(); + if (base.empty()) + { + return ""; + } + + const std::string dir = base + "\\events"; + + std::error_code ec; + fs::create_directories(dir, ec); + if (ec) + { + return ""; + } + + return dir; + } + + /** + * API ํ‚ค config ํŒŒ์ผ ๊ฒฝ๋กœ ๋ฐ˜ํ™˜ (%APPDATA%/creative-wakatime/wakatime_config.txt). + * ์•ฑ ๋ฐ์ดํ„ฐ ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ์–ป์ง€ ๋ชปํ•˜๋ฉด ๋นˆ ๋ฌธ์ž์—ด. + */ + inline std::string GetConfigFilePath() + { + const std::string base = GetAppDataDir(); + if (base.empty()) + { + return ""; + } + + return base + "\\wakatime_config.txt"; + } } extern WakaTimeClient *g_wakatimeClient; diff --git a/include/inbox_bridge.h b/include/inbox_bridge.h new file mode 100644 index 0000000..fc96659 --- /dev/null +++ b/include/inbox_bridge.h @@ -0,0 +1,39 @@ +#pragma once + +#include "globals.h" + +class WakaTimeClient; +struct HeartbeatData; + +/** + * ์™ธ๋ถ€(Aseprite ๋“ฑ) ์ด๋ฒคํŠธ inbox(.json)๋ฅผ ์†Œ๋น„ํ•ด WakaTimeClient๋กœ heartbeat๋ฅผ ๋ณด๋‚ธ๋‹ค. + * + * Lua extension์ด `%APPDATA%/creative-wakatime/events/`์— `.json` ์ด๋ฒคํŠธ ํŒŒ์ผ์„ ์“ฐ๋ฉด + * FileWatcher์˜ inbox ์ฝœ๋ฐฑ ๋˜๋Š” ์‹œ์ž‘ ์‹œ ์Šค์บ”์œผ๋กœ ๋ฐ›์•„ ํŒŒ์‹ฑ โ†’ EnqueueHeartbeat โ†’ ํŒŒ์ผ ์‚ญ์ œํ•œ๋‹ค. + * + * ์™ธ๋ถ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์—†์ด ๊ณ ์ • ์Šคํ‚ค๋งˆ ํ‚ค๋งŒ ์ถ”์ถœํ•˜๋Š” ๊ฒฝ๋Ÿ‰ ํŒŒ์„œ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค(์ง๋ ฌํ™”๋„ ์ˆ˜๋™). + */ +namespace InboxBridge +{ + /** + * inbox์—์„œ ๊ฐ์ง€๋œ ๋‹จ์ผ .json ํŒŒ์ผ์„ ์ฒ˜๋ฆฌํ•œ๋‹ค. + * ์„ฑ๊ณต/ํŒŒ์‹ฑ ์‹คํŒจ ๋ชจ๋‘ ํŒŒ์ผ์„ ์‚ญ์ œํ•ด ๋ฌดํ•œ ์žฌ์‹œ๋„๋ฅผ ์ฐจ๋‹จํ•œ๋‹ค. + * (์ฝ๊ธฐ ์ž์ฒด ์‹คํŒจ = ์•„์ง ์•ˆ ํ’€๋ฆฐ lock ๋“ฑ์€ ๋‚จ๊ฒจ๋‘๊ณ  ๋‹ค์Œ ๊ธฐํšŒ์— ์žฌ์‹œ๋„) + * @param jsonPath ์ด๋ฒคํŠธ ํŒŒ์ผ ์ „์ฒด ๊ฒฝ๋กœ + * @param client heartbeat๋ฅผ ์ ์žฌํ•  ํด๋ผ์ด์–ธํŠธ + * @param onHeartbeat ์ฒ˜๋ฆฌ ์„ฑ๊ณต ์‹œ ์ƒ์„ฑ๋œ heartbeat๋ฅผ ์ „๋‹ฌ๋ฐ›๋Š” ์„ ํƒ ์ฝœ๋ฐฑ + */ + void ProcessFile(const std::string& jsonPath, WakaTimeClient* client, + const std::function& onHeartbeat = {}); + + /** + * ์•ฑ ์‹œ์ž‘ ์‹œ events/ ํด๋”์— ์Œ“์ธ ์ž”์—ฌ .json์„ 1ํšŒ ์Šค์บ”ยท์†Œ๋น„ํ•œ๋‹ค. + * ReadDirectoryChangesW๋Š” ๊ธฐ์กด ํŒŒ์ผ์— ์ด๋ฒคํŠธ๋ฅผ ์ฃผ์ง€ ์•Š์œผ๋ฏ€๋กœ ํ•„์ˆ˜. + * @param eventsDir ์ด๋ฒคํŠธ ํด๋” ๊ฒฝ๋กœ + * @param client heartbeat๋ฅผ ์ ์žฌํ•  ํด๋ผ์ด์–ธํŠธ + * @param onHeartbeat ์ฒ˜๋ฆฌ ์„ฑ๊ณต ์‹œ ์ƒ์„ฑ๋œ heartbeat๋ฅผ ์ „๋‹ฌ๋ฐ›๋Š” ์„ ํƒ ์ฝœ๋ฐฑ + * @return ์ฒ˜๋ฆฌํ•œ ํŒŒ์ผ ์ˆ˜ + */ + int InitialScan(const std::string& eventsDir, WakaTimeClient* client, + const std::function& onHeartbeat = {}); +} diff --git a/include/tray_icon.h b/include/tray_icon.h index b058fea..b2862b3 100644 --- a/include/tray_icon.h +++ b/include/tray_icon.h @@ -35,7 +35,7 @@ class TrayIcon { // ์ƒํƒœ ์ •๋ณด bool isMonitoring; // ๋ชจ๋‹ˆํ„ฐ๋ง ํ™œ์„ฑ ์ƒํƒœ - std::string currentProject; // ํ˜„์žฌ ํ™œ์„ฑ ํ”„๋กœ์ ํŠธ + std::string activeContext; // ํ˜„์žฌ ํ™œ์„ฑ ์ปจํ…์ŠคํŠธ(ํ”„๋กœ์ ํŠธ/๋„๊ตฌ) int totalHeartbeats; // ์ด heartbeat ์ˆ˜ bool initialized; // ์ดˆ๊ธฐํ™” ์ƒํƒœ @@ -145,10 +145,16 @@ class TrayIcon { * @param appName ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ด๋ฆ„ * @return ์„ฑ๊ณตํ•˜๋ฉด true */ - bool Initialize(const std::string& appName = "Unity WakaTime"); + bool Initialize(const std::string& appName = "Creative WakaTime"); /** - * ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ์„ค์ • (ํˆดํŒ ์—…๋ฐ์ดํŠธ) + * ํ˜„์žฌ ํ™œ๋™ ์ปจํ…์ŠคํŠธ ์„ค์ • (ํˆดํŒ ์—…๋ฐ์ดํŠธ) + * @param contextName ํ”„๋กœ์ ํŠธ ๋˜๋Š” ๋„๊ตฌ ์ด๋ฆ„ + */ + void SetActiveContext(const std::string& contextName); + + /** + * ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ์„ค์ • (์ด์ „ API ํ˜ธํ™˜์šฉ) * @param projectName ํ”„๋กœ์ ํŠธ ์ด๋ฆ„ */ void SetCurrentProject(const std::string& projectName); diff --git a/include/wakatime_client.h b/include/wakatime_client.h index 948b299..8b7a627 100644 --- a/include/wakatime_client.h +++ b/include/wakatime_client.h @@ -3,6 +3,7 @@ #include "globals.h" #include // Windows HTTP API #include +#include #include #pragma comment(lib, "winhttp.lib") // WinHTTP ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋งํฌ @@ -47,9 +48,9 @@ class WakaTimeClient { mutable std::mutex queueMutex; // ํ ์ ‘๊ทผ ๋™๊ธฐํ™” std::condition_variable queueCv; // ํ ๋Œ€๊ธฐ/ํ†ต์ง€ (busy-poll ์ œ๊ฑฐ) std::queue heartbeatQueue; // ์ „์†ก ๋Œ€๊ธฐ ํ - std::chrono::steady_clock::time_point lastQueuedAt; - std::string lastQueuedEntity; - std::string lastQueuedProject; + // entity+project๋ณ„ ๋งˆ์ง€๋ง‰ ์ ์žฌ ์‹œ๊ฐ. Unity/Aseprite ํŒŒ์ผ์ด ๋ฒˆ๊ฐˆ์•„ ๋“ค์–ด์™€๋„ + // ์„œ๋กœ์˜ debounce๋ฅผ ๋ฌดํšจํ™”ํ•˜์ง€ ์•Š๋„๋ก ์ปจํ…์ŠคํŠธ๋ณ„๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. + std::unordered_map lastQueuedByEntity; std::thread senderThread; // ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ „์†ก ์Šค๋ ˆ๋“œ std::atomic shouldStop; // ์Šค๋ ˆ๋“œ ์ข…๋ฃŒ ํ”Œ๋ž˜๊ทธ @@ -104,9 +105,19 @@ class WakaTimeClient { /** * ์‹ค์ œ HTTP POST ์š”์ฒญ ์ˆ˜ํ–‰ * @param jsonData ์ „์†กํ•  JSON ๋ฐ์ดํ„ฐ + * @param heartbeat User-Agent ๊ตฌ์„ฑ์„ ์œ„ํ•œ heartbeat (editor ์‹๋ณ„์šฉ) * @return ์„ฑ๊ณตํ•˜๋ฉด true */ - bool SendHttpRequest(const std::string& jsonData); + bool SendHttpRequest(const std::string& jsonData, const HeartbeatData& heartbeat); + + /** + * heartbeat์˜ editor์— ๋งž์ถฐ WakaTime User-Agent ๋ฌธ์ž์—ด์„ ๊ตฌ์„ฑํ•œ๋‹ค. + * WakaTime ๋Œ€์‹œ๋ณด๋“œ๋Š” User-Agent๋กœ editor/plugin์„ ์‹๋ณ„ํ•˜๋ฏ€๋กœ + * Unity/Aseprite๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ ์ง‘๊ณ„ํ•˜๋ ค๋ฉด editor๋ณ„๋กœ ๋‹ค๋ฅธ UA๊ฐ€ ํ•„์š”ํ•˜๋‹ค. + * @param heartbeat editor ์ •๋ณด๋ฅผ ๋‹ด์€ heartbeat + * @return User-Agent ๋ฌธ์ž์—ด + */ + std::string BuildUserAgent(const HeartbeatData& heartbeat) const; /** * ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ „์†ก ์Šค๋ ˆ๋“œ ํ•จ์ˆ˜ @@ -171,6 +182,14 @@ class WakaTimeClient { * @param event ํŒŒ์ผ ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ */ void SendHeartbeatFromEvent(const FileChangeEvent& event); + + /** + * ์™ธ๋ถ€์—์„œ ์™„์„ฑ๋œ HeartbeatData๋ฅผ ์ „์†ก ํ์— ์ ์žฌ (๋น„๋™๊ธฐ). + * entity+project๋ณ„ debounce๋ฅผ ์ ์šฉํ•˜๋ฉฐ, Unity ๋‚ด๋ถ€ ๊ฒฝ๋กœ์™€ Aseprite ๋“ฑ + * ์™ธ๋ถ€(inbox) heartbeat๊ฐ€ ๊ณต์œ ํ•˜๋Š” ๋‹จ์ผ ์ง„์ž…์ ์ด๋‹ค. + * @param heartbeat ์ ์žฌํ•  heartbeat ๋ฐ์ดํ„ฐ + */ + void EnqueueHeartbeat(const HeartbeatData& heartbeat); /** * ์ „์†ก ํ์— ๋Œ€๊ธฐ ์ค‘์ธ heartbeat ์ˆ˜ diff --git a/main.cpp b/main.cpp index 706b5b8..d188286 100644 --- a/main.cpp +++ b/main.cpp @@ -2,6 +2,7 @@ #include "process_monitor.h" #include "file_watcher.h" #include "wakatime_client.h" +#include "inbox_bridge.h" #include "tray_icon.h" #include "unity_focus_detector.h" #include "windows_dark_mode.h" @@ -53,6 +54,35 @@ void OnFileChanged(const FileChangeEvent &event) } } +void OnInboxHeartbeat(const HeartbeatData &heartbeat) +{ + if (!g_trayIcon) return; + + g_trayIcon->IncrementHeartbeats(); + const std::string editor = heartbeat.editor.empty() ? "Creative tool" : heartbeat.editor; + if (!heartbeat.project.empty()) + { + g_trayIcon->SetActiveContext(editor + " - " + heartbeat.project); + } + else + { + g_trayIcon->SetActiveContext(editor); + } +} + +// ์™ธ๋ถ€ ์ด๋ฒคํŠธ(.json) inbox ํŒŒ์ผ ์ฒ˜๋ฆฌ - Aseprite ๋“ฑ์ด ๋–จ์–ด๋œจ๋ฆฐ ์ด๋ฒคํŠธ๋ฅผ heartbeat๋กœ ๋ณ€ํ™˜ +void OnInboxFile(const std::string &jsonPath) +{ + if (!g_wakatimeClient) return; + if (!g_wakatimeClient->IsInitialized()) + { + WT_LOG("[INBOX] โš ๏ธ Skipped - WakaTime client not initialized: " << jsonPath); + return; + } + + InboxBridge::ProcessFile(jsonPath, g_wakatimeClient, OnInboxHeartbeat); +} + // ํŠธ๋ ˆ์ด ์•„์ด์ฝ˜ ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋“ค void OnTrayExit() { @@ -101,7 +131,8 @@ void OnTrayShowSettings() { std::ostringstream settings; settings << "API Key: " << g_wakatimeClient->GetMaskedApiKey() << "\n" - << "Watching " << g_fileWatcher->GetWatchedProjectCount() << " Unity projects"; + << "Unity projects watched: " << g_fileWatcher->GetWatchedProjectCount() << "\n" + << "Aseprite inbox: active"; g_trayIcon->ShowInfoNotification(settings.str()); } @@ -169,8 +200,8 @@ void HandleNewUnityInstances(const std::vector &newInstances) if (g_trayIcon) { g_trayIcon->SetCurrentProject(instance.projectName); - g_trayIcon->ShowInfoNotification("New Unity project: " + instance.projectName + - " (Unity " + instance.editorVersion + ")"); + g_trayIcon->ShowInfoNotification("Unity detected: " + instance.projectName + + " (" + instance.editorVersion + ")"); } } else @@ -193,7 +224,7 @@ void HandleClosedUnityInstances(const std::vector &closedInstance if (g_trayIcon) { - g_trayIcon->ShowInfoNotification("Unity project closed: " + instance.projectName); + g_trayIcon->ShowInfoNotification("Unity closed: " + instance.projectName); // ๋‹ค๋ฅธ ํ™œ์„ฑ ํ”„๋กœ์ ํŠธ๋กœ ์ „ํ™˜ if (g_fileWatcher) @@ -255,7 +286,7 @@ void CALLBACK FocusWinEventProc(HWINEVENTHOOK, const DWORD event, const HWND hwn int main() { - WT_LOG("[Main] Unity WakaTime Monitor Starting..."); + WT_LOG("[Main] Creative WakaTime Monitor Starting..."); const bool darkModeAvailable = WindowsDarkMode::EnableForApp(); WT_LOG("[Main] Dark mode menu opt-in: " << (darkModeAvailable ? "enabled" : "not available")); @@ -263,7 +294,7 @@ int main() TrayIcon trayIcon; g_trayIcon = &trayIcon; - if (!trayIcon.Initialize("Unity WakaTime")) + if (!trayIcon.Initialize("Creative WakaTime")) { WT_ERR("[Main] Failed to initialize tray icon!"); return 1; @@ -277,7 +308,7 @@ int main() trayIcon.SetShowSettingsCallback(OnTrayShowSettings); trayIcon.SetApiKeyChangeCallback(OnApiKeyChanged); - trayIcon.ShowInfoNotification("Unity WakaTime started !"); + trayIcon.ShowInfoNotification("Creative WakaTime started !"); WakaTimeClient wakatimeClient; g_wakatimeClient = &wakatimeClient; @@ -294,12 +325,33 @@ int main() g_fileWatcher = &fileWatcher; fileWatcher.SetChangeCallback(OnFileChanged); + fileWatcher.SetInboxCallback(OnInboxFile); // ์›Œ์ปค ์Šค๋ ˆ๋“œ์˜ ํŒŒ์ผ ๋ณ€๊ฒฝ โ†’ ๋ฉ”์ธ ์Šค๋ ˆ๋“œ๋กœ PostMessage ๋งˆ์ƒฌ๋ง (InitialScan ์ด์ „์— ์„ค์น˜) fileWatcher.SetNotifyCallback([&trayIcon]() { trayIcon.NotifyFileEvent(); }); + // ์™ธ๋ถ€ ์ด๋ฒคํŠธ inbox(%APPDATA%/creative-wakatime/events) ๊ฐ์‹œ ์‹œ์ž‘ + ์ž”์—ฌ ์ฒ˜๋ฆฌ. + // ์•ฑ์ด ๊บผ์ง„ ๋™์•ˆ ์Œ“์ธ ์ด๋ฒคํŠธ๋Š” ReadDirectoryChangesW๊ฐ€ ํ†ต์ง€ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ์‹œ์ž‘ ์‹œ 1ํšŒ ์Šค์บ”. + const std::string eventsDir = Config::GetEventsDir(); + if (!eventsDir.empty()) + { + if (fileWatcher.StartWatchingInbox(eventsDir)) + { + WT_LOG("[Main] Watching external events inbox: " << eventsDir); + } + else + { + WT_ERR("[Main] Failed to watch events inbox: " << eventsDir); + } + InboxBridge::InitialScan(eventsDir, &wakatimeClient, OnInboxHeartbeat); + } + else + { + WT_ERR("[Main] Could not resolve events directory (APPDATA missing)"); + } + UnityFocusDetector unityFocusDetector; g_unityFocusDetector = &unityFocusDetector; @@ -311,7 +363,7 @@ int main() trayIcon.SetMonitoringState(true); - WT_LOG("\n[Main] Unity WakaTime is now running in background!"); + WT_LOG("\n[Main] Creative WakaTime is now running in background!"); // ์ด๋ฒคํŠธ ํ—ˆ๋ธŒ ์ฝœ๋ฐฑ ๋ฐฐ์„  (TrayIcon์˜ ๋ฉ”์‹œ์ง€ ํŽŒํ”„์—์„œ ๋””์ŠคํŒจ์น˜) trayIcon.SetFileEventCallback([]() @@ -348,8 +400,8 @@ int main() if (focusHook) UnhookWinEvent(focusHook); - WT_LOG("\n[Main] Shutting down Unity WakaTime..."); - trayIcon.ShowInfoNotification("Unity WakaTime shutting down..."); + WT_LOG("\n[Main] Shutting down Creative WakaTime..."); + trayIcon.ShowInfoNotification("Creative WakaTime shutting down..."); // ๋‚จ์€ heartbeat ์ „์†ก if (g_wakatimeClient) @@ -372,6 +424,6 @@ int main() Globals::Cleanup(); - WT_LOG("[Main] Unity WakaTime stopped gracefully."); + WT_LOG("[Main] Creative WakaTime stopped gracefully."); return 0; } diff --git a/src/file_watcher.cpp b/src/file_watcher.cpp index 6522b02..f247169 100644 --- a/src/file_watcher.cpp +++ b/src/file_watcher.cpp @@ -30,6 +30,80 @@ void FileWatcher::SetNotifyCallback(std::function callback) WT_LOG("[FileWatcher] Notify callback set"); } +void FileWatcher::SetInboxCallback(std::function callback) +{ + inboxCallback = std::move(callback); + WT_LOG("[FileWatcher] Inbox callback set"); +} + +bool FileWatcher::StartWatchingInbox(const std::string &inboxPath) +{ + std::lock_guard lock(projectsMutex); + + // ์ด๋ฏธ ๊ฐ์‹œ ์ค‘์ธ์ง€ ํ™•์ธ + for (const auto &project: watchedProjects) + { + if (project->projectPath == inboxPath) return true; + } + + if (!fs::exists(inboxPath)) + { + WT_ERR("[FileWatcher] Inbox path does not exist: " << inboxPath); + return false; + } + + auto project = std::make_unique(); + if (project->stopEvent == nullptr || project->ioEvent == nullptr) + { + WT_ERR("[FileWatcher] Failed to create watcher events for inbox: " << inboxPath); + return false; + } + + project->projectPath = inboxPath; + project->projectName = "events"; + project->kind = Kind::Inbox; + project->recursive = FALSE; // events ํด๋”๋Š” ํ‰๋ฉด ๊ตฌ์กฐ๋ผ ํ•˜์œ„ ํŠธ๋ฆฌ ๊ฐ์‹œ ๋ถˆํ•„์š” + project->shouldStop = false; + + ZeroMemory(&project->overlapped, sizeof(OVERLAPPED)); + project->overlapped.hEvent = project->ioEvent; + ZeroMemory(project->buffer, sizeof(project->buffer)); + + project->directoryHandle = CreateFileA( + inboxPath.c_str(), + FILE_LIST_DIRECTORY, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, + nullptr + ); + + if (project->directoryHandle == INVALID_HANDLE_VALUE) + { + const DWORD error = GetLastError(); + WT_ERR("[FileWatcher] Failed to open inbox directory: " << inboxPath << " (Error: " << error << ")"); + return false; + } + + WT_LOG("[FileWatcher] Started watching inbox at " << inboxPath); + + WatchedProject *projectPtr = project.get(); + try + { + project->watchThread = std::thread(&FileWatcher::WatchProjectThread, this, projectPtr); + } + catch (const std::system_error &e) + { + WT_ERR("[FileWatcher] Failed to start inbox watch thread: " << e.what()); + return false; + } + + watchedProjects.push_back(std::move(project)); + + return true; +} + bool FileWatcher::StartWatching(const std::string &projectPath, const std::string &projectName, const std::string &unityVersion) { std::lock_guard lock(projectsMutex); @@ -128,12 +202,12 @@ void FileWatcher::WatchProjectThread(WatchedProject *project) ResetEvent(project->ioEvent); // ReadDirectoryChangesW: ๋””๋ ‰ํ† ๋ฆฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ๊ฐ์ง€ํ•˜๋Š” ํ•ต์‹ฌ ํ•จ์ˆ˜ - // TRUE: ํ•˜์œ„ ํด๋”๋„ ๊ฐ์‹œ + // recursive: Unity๋Š” TRUE(ํ•˜์œ„ ํด๋” ํฌํ•จ), inbox๋Š” FALSE(ํ‰๋ฉด ๊ตฌ์กฐ) const BOOL result = ReadDirectoryChangesW( project->directoryHandle, // ๋””๋ ‰ํ† ๋ฆฌ ํ•ธ๋“ค project->buffer, // ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›์„ ๋ฒ„ํผ sizeof(project->buffer), // ๋ฒ„ํผ ํฌ๊ธฐ - TRUE, // ํ•˜์œ„ ๋””๋ ‰ํ† ๋ฆฌ ํฌํ•จ + project->recursive, // ํ•˜์œ„ ๋””๋ ‰ํ† ๋ฆฌ ํฌํ•จ ์—ฌ๋ถ€ FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_CREATION | FILE_NOTIFY_CHANGE_FILE_NAME, &bytesReturned, // ๋ฐ›์€ ๋ฐ์ดํ„ฐ ํฌ๊ธฐ &project->overlapped, // ๋น„๋™๊ธฐ I/O ๊ตฌ์กฐ์ฒด @@ -233,49 +307,89 @@ void FileWatcher::ProcessFileChanges(char *buffer, DWORD bytesReturned, WatchedP std::replace(fullPath.begin(), fullPath.end(), '\\', '/'); std::replace(fileName.begin(), fileName.end(), '\\', '/'); - // ๋ฌด์‹œํ•  ํด๋”์ธ์ง€ ํ™•์ธ - bool shouldIgnore = false; - std::istringstream pathStream(fileName); - std::string segment; - - while (std::getline(pathStream, segment, '/')) + if (project->kind == Kind::Inbox) { - if (ShouldIgnoreFolder(segment)) + // inbox: IGNORE_FOLDERS/IsUnityFile ๊ฑด๋„ˆ๋›ฐ๊ณ , .json ์ถ”๊ฐ€/์ด๋ฆ„๋ณ€๊ฒฝ/์ˆ˜์ •๋งŒ ์ฒ˜๋ฆฌ. + // Aseprite Lua sandbox์—๋Š” rename์ด ์—†์–ด .json์— ์ง์ ‘ ์“ฐ๋ฉฐ, ๋นˆ/๋ฏธ์™„์„ฑ ํŒŒ์ผ์€ + // InboxBridge๊ฐ€ ์‚ญ์ œํ•˜์ง€ ์•Š๊ณ  ๋‹ค์Œ ์ด๋ฒคํŠธ๋‚˜ ์‹œ์ž‘ ์Šค์บ”์—์„œ ์žฌ์‹œ๋„ํ•œ๋‹ค. + const bool relevantAction = + (info->Action == FILE_ACTION_ADDED || + info->Action == FILE_ACTION_RENAMED_NEW_NAME || + info->Action == FILE_ACTION_MODIFIED); + + const bool isJson = [&fileName]() { + if (fileName.size() < 5) return false; + std::string ext = fileName.substr(fileName.size() - 5); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](const unsigned char c) { return static_cast(::tolower(c)); }); + return ext == ".json"; + }(); + + if (relevantAction && isJson) { - shouldIgnore = true; - break; + WT_LOG("[FileWatcher] Inbox event: " << fileName); + + { + std::lock_guard lock(pendingInboxMutex); + while (pendingInboxFiles.size() >= kMaxPendingEvents) + { + pendingInboxFiles.pop_front(); + } + pendingInboxFiles.emplace_back(std::move(fullPath)); + } + + if (notifyCallback && !notifyScheduled.exchange(true)) + { + notifyCallback(); + } } } - - // Unity ํŒŒ์ผ์ด๊ณ  ๋ฌด์‹œํ•  ํด๋”๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ์—๋งŒ ์ฒ˜๋ฆฌ - if (!shouldIgnore && IsUnityFile(fileName)) + else { - // FileChangeEvent ์ƒ์„ฑ - FileChangeEvent event; - event.filePath = fullPath; - event.fileName = fileName; - event.projectPath = project->projectPath; - event.projectName = project->projectName; - event.unityVersion = project->unityVersion; - event.action = info->Action; - event.timestamp = std::chrono::system_clock::now(); - - WT_LOG("[FileWatcher] Change" << ": " << fileName << " in " << project->projectName); - - // ์›Œ์ปค ์Šค๋ ˆ๋“œ์—์„œ ์ง์ ‘ UI/์•ฑ ์ฝœ๋ฐฑ์„ ํ˜ธ์ถœํ•˜์ง€ ์•Š๊ณ  ํ์— ์ ์žฌ + // ๋ฌด์‹œํ•  ํด๋”์ธ์ง€ ํ™•์ธ + bool shouldIgnore = false; + std::istringstream pathStream(fileName); + std::string segment; + + while (std::getline(pathStream, segment, '/')) { - std::lock_guard lock(pendingEventsMutex); - while (pendingEvents.size() >= kMaxPendingEvents) + if (ShouldIgnoreFolder(segment)) { - pendingEvents.pop_front(); + shouldIgnore = true; + break; } - pendingEvents.emplace_back(std::move(event)); } - // ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์— ํ†ต์ง€ (์ฝ”์–ผ๋ ˆ์‹ฑ: ์ด๋ฏธ ์˜ˆ์•ฝ๋ผ ์žˆ์œผ๋ฉด ์ถ”๊ฐ€ post ์ƒ๋žต) - if (notifyCallback && !notifyScheduled.exchange(true)) + // Unity ํŒŒ์ผ์ด๊ณ  ๋ฌด์‹œํ•  ํด๋”๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ์—๋งŒ ์ฒ˜๋ฆฌ + if (!shouldIgnore && IsUnityFile(fileName)) { - notifyCallback(); + // FileChangeEvent ์ƒ์„ฑ + FileChangeEvent event; + event.filePath = fullPath; + event.fileName = fileName; + event.projectPath = project->projectPath; + event.projectName = project->projectName; + event.unityVersion = project->unityVersion; + event.action = info->Action; + event.timestamp = std::chrono::system_clock::now(); + + WT_LOG("[FileWatcher] Change" << ": " << fileName << " in " << project->projectName); + + // ์›Œ์ปค ์Šค๋ ˆ๋“œ์—์„œ ์ง์ ‘ UI/์•ฑ ์ฝœ๋ฐฑ์„ ํ˜ธ์ถœํ•˜์ง€ ์•Š๊ณ  ํ์— ์ ์žฌ + { + std::lock_guard lock(pendingEventsMutex); + while (pendingEvents.size() >= kMaxPendingEvents) + { + pendingEvents.pop_front(); + } + pendingEvents.emplace_back(std::move(event)); + } + + // ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์— ํ†ต์ง€ (์ฝ”์–ผ๋ ˆ์‹ฑ: ์ด๋ฏธ ์˜ˆ์•ฝ๋ผ ์žˆ์œผ๋ฉด ์ถ”๊ฐ€ post ์ƒ๋žต) + if (notifyCallback && !notifyScheduled.exchange(true)) + { + notifyCallback(); + } } } } @@ -299,7 +413,8 @@ bool FileWatcher::IsUnityFile(const std::string &fileName) const if (dotPos == std::string::npos) return false; // ํ™•์žฅ์ž๊ฐ€ ์—†์œผ๋ฉด ๋ฌด์‹œ std::string extension = fileName.substr(dotPos); - std::transform(extension.begin(), extension.end(), extension.begin(), tolower); // ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ + std::transform(extension.begin(), extension.end(), extension.begin(), + [](const unsigned char c) { return static_cast(::tolower(c)); }); // ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ const auto &extensions = Config::GetUnityExtensions(); return extensions.find(extension) != extensions.end(); @@ -308,13 +423,15 @@ bool FileWatcher::IsUnityFile(const std::string &fileName) const bool FileWatcher::ShouldIgnoreFolder(const std::string &folderName) const { std::string normalizedFolder = folderName; - std::transform(normalizedFolder.begin(), normalizedFolder.end(), normalizedFolder.begin(), tolower); + std::transform(normalizedFolder.begin(), normalizedFolder.end(), normalizedFolder.begin(), + [](const unsigned char c) { return static_cast(::tolower(c)); }); const auto &ignoreFolders = Config::GetIgnoreFolders(); for (const auto &ignoreFolder: ignoreFolders) { std::string normalizedIgnore = ignoreFolder; - std::transform(normalizedIgnore.begin(), normalizedIgnore.end(), normalizedIgnore.begin(), tolower); + std::transform(normalizedIgnore.begin(), normalizedIgnore.end(), normalizedIgnore.begin(), + [](const unsigned char c) { return static_cast(::tolower(c)); }); if (normalizedFolder == normalizedIgnore) { return true; @@ -401,7 +518,7 @@ void FileWatcher::StopAllWatching() void FileWatcher::DrainPendingEvents(const size_t maxEvents) { - if (!changeCallback || maxEvents == 0) + if (maxEvents == 0) { return; } @@ -409,29 +526,52 @@ void FileWatcher::DrainPendingEvents(const size_t maxEvents) // ๋“œ๋ ˆ์ธ ์‹œ์ž‘ ์ „์— ์˜ˆ์•ฝ ํ”Œ๋ž˜๊ทธ๋ฅผ ๋‚ด๋ ค, ์ด ์‹œ์  ์ดํ›„ ๋„์ฐฉํ•˜๋Š” ์ด๋ฒคํŠธ๋Š” ์ƒˆ ํ†ต์ง€๋ฅผ postํ•˜๋„๋ก ํ•œ๋‹ค. notifyScheduled.store(false); - std::vector localEvents; bool moreRemaining = false; + + // 1) Unity ํŒŒ์ผ ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ๋“œ๋ ˆ์ธ + if (changeCallback) { - std::lock_guard lock(pendingEventsMutex); - if (pendingEvents.empty()) + std::vector localEvents; { - return; + std::lock_guard lock(pendingEventsMutex); + const size_t count = std::min(maxEvents, pendingEvents.size()); + localEvents.reserve(count); + for (size_t i = 0; i < count; ++i) + { + localEvents.emplace_back(std::move(pendingEvents.front())); + pendingEvents.pop_front(); + } + + if (!pendingEvents.empty()) moreRemaining = true; } - const size_t count = std::min(maxEvents, pendingEvents.size()); - localEvents.reserve(count); - for (size_t i = 0; i < count; ++i) + for (const auto &event: localEvents) { - localEvents.emplace_back(std::move(pendingEvents.front())); - pendingEvents.pop_front(); + changeCallback(event); } - - moreRemaining = !pendingEvents.empty(); } - for (const auto &event: localEvents) + // 2) inbox(.json) ์ด๋ฒคํŠธ ๋“œ๋ ˆ์ธ + if (inboxCallback) { - changeCallback(event); + std::vector localInbox; + { + std::lock_guard lock(pendingInboxMutex); + const size_t count = std::min(maxEvents, pendingInboxFiles.size()); + localInbox.reserve(count); + for (size_t i = 0; i < count; ++i) + { + localInbox.emplace_back(std::move(pendingInboxFiles.front())); + pendingInboxFiles.pop_front(); + } + + if (!pendingInboxFiles.empty()) moreRemaining = true; + } + + for (const auto &jsonPath: localInbox) + { + inboxCallback(jsonPath); + } } // maxEvents ํ•œ๋„๋กœ ๋‹ค ๋น„์šฐ์ง€ ๋ชปํ–ˆ์œผ๋ฉด ๋‹ค์Œ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด ํ†ต์ง€๋ฅผ ๋‹ค์‹œ ์˜ˆ์•ฝ @@ -444,7 +584,13 @@ void FileWatcher::DrainPendingEvents(const size_t maxEvents) size_t FileWatcher::GetWatchedProjectCount() const { std::lock_guard lock(projectsMutex); - return watchedProjects.size(); + // inbox ๊ฐ์‹œ๋Š” Unity ํ”„๋กœ์ ํŠธ๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ ์นด์šดํŠธ์—์„œ ์ œ์™ธ + size_t count = 0; + for (const auto &project: watchedProjects) + { + if (project->kind == Kind::Unity) ++count; + } + return count; } std::vector FileWatcher::GetWatchedProjects() const @@ -456,6 +602,7 @@ std::vector FileWatcher::GetWatchedProjects() const for (const auto &project: watchedProjects) { + if (project->kind != Kind::Unity) continue; // inbox ์ œ์™ธ WatchedProjectInfo info; info.projectPath = project->projectPath; info.projectName = project->projectName; diff --git a/src/inbox_bridge.cpp b/src/inbox_bridge.cpp new file mode 100644 index 0000000..492164f --- /dev/null +++ b/src/inbox_bridge.cpp @@ -0,0 +1,288 @@ +#include "inbox_bridge.h" +#include "wakatime_client.h" + +namespace +{ + // JSON ๋ฌธ์ž์—ด ์ด์Šค์ผ€์ดํ”„ ํ•ด์ œ (\" \\ \/ \n \r \t \b \f \uXXXX ์ผ๋ถ€ ์ฒ˜๋ฆฌ) + std::string UnescapeJson(const std::string &raw) + { + std::string out; + out.reserve(raw.size()); + + for (size_t i = 0; i < raw.size(); ++i) + { + const char c = raw[i]; + if (c == '\\' && i + 1 < raw.size()) + { + const char next = raw[++i]; + switch (next) + { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'u': + // \uXXXX: BMP ๋ฒ”์œ„๋งŒ ๋‹จ์ˆœ ์ฒ˜๋ฆฌ (ASCII๋ฉด ๊ทธ๋Œ€๋กœ, ์•„๋‹ˆ๋ฉด UTF-8 ์ธ์ฝ”๋”ฉ) + if (i + 4 < raw.size()) + { + const std::string hex = raw.substr(i + 1, 4); + i += 4; + try + { + const unsigned int cp = std::stoul(hex, nullptr, 16); + if (cp < 0x80) + { + out += static_cast(cp); + } + else if (cp < 0x800) + { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } + else + { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } + } + catch (...) + { + // ๋ฌด์‹œ + } + } + break; + default: out += next; break; + } + } + else + { + out += c; + } + } + + return out; + } + + // ๊ณ ์ • ์Šคํ‚ค๋งˆ์šฉ ๊ฒฝ๋Ÿ‰ ์ถ”์ถœ: "key" : "value" ๋˜๋Š” "key" : value(์ˆซ์ž/๋ถˆ๋ฆฌ์–ธ) ํ˜•ํƒœ. + // ๋ฌธ์ž์—ด ๊ฐ’์ด๋ฉด isString=true. ํ‚ค๋ฅผ ๋ชป ์ฐพ์œผ๋ฉด false. + bool ExtractRawValue(const std::string &json, const std::string &key, + std::string &outValue, bool &outIsString) + { + const std::string needle = "\"" + key + "\""; + size_t pos = json.find(needle); + if (pos == std::string::npos) return false; + + pos += needle.size(); + + // ':' ์ฐพ๊ธฐ + pos = json.find(':', pos); + if (pos == std::string::npos) return false; + ++pos; + + // ๊ณต๋ฐฑ skip + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t' || + json[pos] == '\n' || json[pos] == '\r')) + { + ++pos; + } + if (pos >= json.size()) return false; + + if (json[pos] == '"') + { + // ๋ฌธ์ž์—ด ๊ฐ’: ๋‹ซ๋Š” ๋”ฐ์˜ดํ‘œ(์ด์Šค์ผ€์ดํ”„ ์•ˆ ๋œ)๊นŒ์ง€ + ++pos; + const size_t start = pos; + std::string raw; + while (pos < json.size()) + { + if (json[pos] == '\\' && pos + 1 < json.size()) + { + raw += json[pos]; + raw += json[pos + 1]; + pos += 2; + continue; + } + if (json[pos] == '"') break; + raw += json[pos]; + ++pos; + } + (void)start; + outValue = UnescapeJson(raw); + outIsString = true; + return true; + } + + // ์ˆซ์ž/๋ถˆ๋ฆฌ์–ธ/null: ๊ตฌ๋ถ„์ž(, } ๊ณต๋ฐฑ)๊นŒ์ง€ + const size_t start = pos; + while (pos < json.size() && json[pos] != ',' && json[pos] != '}' && + json[pos] != ' ' && json[pos] != '\t' && json[pos] != '\n' && json[pos] != '\r') + { + ++pos; + } + outValue = json.substr(start, pos - start); + outIsString = false; + return true; + } + + std::string GetString(const std::string &json, const std::string &key) + { + std::string value; + bool isString = false; + if (ExtractRawValue(json, key, value, isString)) + { + return value; + } + return ""; + } + + // ํŒŒ์ผ ์ „์ฒด๋ฅผ ๋ฌธ์ž์—ด๋กœ ์ฝ๊ธฐ. ์‹คํŒจ(์—ด๊ธฐ ์‹คํŒจ/๋นˆ ํŒŒ์ผ)๋ฉด false. + bool ReadWholeFile(const std::string &path, std::string &out) + { + std::ifstream file(path, std::ios::binary); + if (!file.is_open()) return false; + + std::ostringstream ss; + ss << file.rdbuf(); + out = ss.str(); + + return !out.empty(); + } + + bool IsProbablyCompleteJson(const std::string &content) + { + const auto first = content.find_first_not_of(" \t\r\n"); + if (first == std::string::npos || content[first] != '{') return false; + + const auto last = content.find_last_not_of(" \t\r\n"); + return last != std::string::npos && content[last] == '}'; + } +} + +namespace InboxBridge +{ + void ProcessFile(const std::string &jsonPath, WakaTimeClient *client, + const std::function &onHeartbeat) + { + if (client == nullptr) return; + + std::string content; + if (!ReadWholeFile(jsonPath, content)) + { + // ์•„์ง ์•ˆ ํ’€๋ฆฐ lock์ด๊ฑฐ๋‚˜ ๋นˆ ํŒŒ์ผ โ†’ ์‚ญ์ œํ•˜์ง€ ์•Š๊ณ  ๋‹ค์Œ ๊ธฐํšŒ์— ์žฌ์‹œ๋„. + WT_LOG("[InboxBridge] Skip (unreadable/empty): " << jsonPath); + return; + } + + if (!IsProbablyCompleteJson(content)) + { + // Aseprite Lua๋Š” .json์— ์ง์ ‘ ์“ฐ๋ฏ€๋กœ ํŒŒ์ผ ์“ฐ๊ธฐ ์ค‘ MODIFIED ์ด๋ฒคํŠธ๊ฐ€ ๋จผ์ € ์˜ฌ ์ˆ˜ ์žˆ๋‹ค. + // ์ด ๊ฒฝ์šฐ ์‚ญ์ œํ•˜์ง€ ์•Š๊ณ  ๋‹ค์Œ MODIFIED/์‹œ์ž‘ ์Šค์บ”์—์„œ ์žฌ์‹œ๋„ํ•œ๋‹ค. + WT_LOG("[InboxBridge] Skip (incomplete json): " << jsonPath); + return; + } + + // ํ•„์ˆ˜ ํ‚ค ์ถ”์ถœ + const std::string entity = GetString(content, "entity"); + if (entity.empty()) + { + // ์Šคํ‚ค๋งˆ ์œ„๋ฐ˜ โ†’ ๋ฌดํ•œ ์žฌ์‹œ๋„ ๋ฐฉ์ง€ ์œ„ํ•ด ์‚ญ์ œ + WT_ERR("[InboxBridge] Invalid event (no entity), deleting: " << jsonPath); + std::error_code ec; + fs::remove(jsonPath, ec); + return; + } + + HeartbeatData hb; + hb.entity = entity; + hb.project = GetString(content, "project"); + hb.language = GetString(content, "language"); + hb.editor = GetString(content, "editor"); + if (hb.editor.empty()) hb.editor = "Aseprite"; + if (hb.language.empty()) hb.language = hb.editor; + + // is_write (๋ถˆ๋ฆฌ์–ธ) + std::string isWriteRaw; + bool isStr = false; + hb.is_write = false; + if (ExtractRawValue(content, "is_write", isWriteRaw, isStr)) + { + hb.is_write = (isWriteRaw == "true" || isWriteRaw == "1"); + } + + // time (Unix timestamp). ์—†๊ฑฐ๋‚˜ 0์ด๋ฉด ํ˜„์žฌ ์‹œ๊ฐ. + std::string timeRaw; + hb.time = 0; + if (ExtractRawValue(content, "time", timeRaw, isStr) && !timeRaw.empty()) + { + try + { + hb.time = static_cast(std::stoll(timeRaw)); + } + catch (...) + { + hb.time = 0; + } + } + if (hb.time <= 0) + { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + hb.time = std::chrono::duration_cast(now).count(); + } + + WT_LOG("[InboxBridge] Heartbeat: " << hb.entity << " (" << hb.project + << ", " << hb.editor << ", write=" << (hb.is_write ? "1" : "0") << ")"); + + client->EnqueueHeartbeat(hb); + if (onHeartbeat) + { + onHeartbeat(hb); + } + + // ์ฒ˜๋ฆฌ ์„ฑ๊ณต โ†’ ํŒŒ์ผ ์‚ญ์ œ + std::error_code ec; + fs::remove(jsonPath, ec); + if (ec) + { + WT_ERR("[InboxBridge] Failed to delete consumed event: " << jsonPath); + } + } + + int InitialScan(const std::string &eventsDir, WakaTimeClient *client, + const std::function &onHeartbeat) + { + if (client == nullptr || eventsDir.empty()) return 0; + + std::error_code ec; + if (!fs::exists(eventsDir, ec)) return 0; + + int processed = 0; + for (const auto &entry: fs::directory_iterator(eventsDir, ec)) + { + if (ec) break; + if (!entry.is_regular_file(ec)) continue; + + const auto &path = entry.path(); + std::string ext = path.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](const unsigned char c) { return static_cast(::tolower(c)); }); + if (ext != ".json") continue; + + std::string full = path.string(); + std::replace(full.begin(), full.end(), '\\', '/'); + ProcessFile(full, client, onHeartbeat); + ++processed; + } + + if (processed > 0) + { + WT_LOG("[InboxBridge] Initial scan processed " << processed << " event(s)"); + } + + return processed; + } +} diff --git a/src/process_monitor.cpp b/src/process_monitor.cpp index b4af22f..ff4022f 100644 --- a/src/process_monitor.cpp +++ b/src/process_monitor.cpp @@ -234,7 +234,8 @@ void ProcessMonitor::PollChanges(std::vector& started, std::vecto std::string ProcessMonitor::ExtractProjectPath(const std::string &commandLine) { std::string lowerCommandLine = commandLine; - std::transform(lowerCommandLine.begin(), lowerCommandLine.end(), lowerCommandLine.begin(), tolower); + std::transform(lowerCommandLine.begin(), lowerCommandLine.end(), lowerCommandLine.begin(), + [](const unsigned char c) { return static_cast(::tolower(c)); }); constexpr char projectPathArg[] = "-projectpath"; size_t pos = lowerCommandLine.find(projectPathArg); diff --git a/src/tray_icon.cpp b/src/tray_icon.cpp index ccaabab..02c92bb 100644 --- a/src/tray_icon.cpp +++ b/src/tray_icon.cpp @@ -66,7 +66,7 @@ bool TrayIcon::CreateHiddenWindow() // ์ฐฝ ํด๋ž˜์Šค ๋“ฑ๋ก wc.lpfnWndProc = WindowProc; // ์œˆ๋„์šฐ ํ”„๋กœ์‹œ์ € wc.hInstance = GetModuleHandle(nullptr); // ํ˜„์žฌ ๋ชจ๋“ˆ ํ•ธ๋“ค - wc.lpszClassName = L"UnityWakaTimeTray"; // ํด๋ž˜์Šค ์ด๋ฆ„ + wc.lpszClassName = L"CreativeWakaTimeTray"; // ํด๋ž˜์Šค ์ด๋ฆ„ wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground = (HBRUSH) (COLOR_WINDOW + 1); @@ -84,8 +84,8 @@ bool TrayIcon::CreateHiddenWindow() // WS_OVERLAPPED: ๊ธฐ๋ณธ ์ฐฝ ์Šคํƒ€์ผ // CW_USEDEFAULT: ๊ธฐ๋ณธ ์œ„์น˜/ํฌ๊ธฐ hwnd = CreateWindowW( - L"UnityWakaTimeTray", // ํด๋ž˜์Šค ์ด๋ฆ„ - L"Unity WakaTime", // ์ฐฝ ์ œ๋ชฉ + L"CreativeWakaTimeTray", // ํด๋ž˜์Šค ์ด๋ฆ„ + L"Creative WakaTime", // ์ฐฝ ์ œ๋ชฉ WS_OVERLAPPED, // ์ฐฝ ์Šคํƒ€์ผ CW_USEDEFAULT, CW_USEDEFAULT, // ์œ„์น˜ 1, 1, // ํฌ๊ธฐ (์ตœ์†Œ) @@ -117,7 +117,7 @@ bool TrayIcon::CreateTrayIcon() { nid.hIcon = LoadPngIcon("logo_32.png"); - wcscpy_s(nid.szTip, L"Unity WakaTime - Starting..."); + wcscpy_s(nid.szTip, L"Creative WakaTime - Starting..."); if (!Shell_NotifyIconW(NIM_ADD, &nid)) { DWORD error = GetLastError(); @@ -340,14 +340,14 @@ HMENU TrayIcon::CreateStatusSubMenu() const std::wstring monitoringStatus = isMonitoring ? L"Monitoring: Active" : L"Monitoring: Paused"; AppendMenuW(subMenu, MF_STRING | MF_GRAYED, 0, monitoringStatus.c_str()); - // Current project - if (!currentProject.empty()) + // Active context + if (!activeContext.empty()) { - const std::wstring projectInfo = L"Current Project: " + std::wstring(currentProject.begin(), currentProject.end()); - AppendMenuW(subMenu, MF_STRING | MF_GRAYED, 0, projectInfo.c_str()); + const std::wstring contextInfo = L"Active: " + std::wstring(activeContext.begin(), activeContext.end()); + AppendMenuW(subMenu, MF_STRING | MF_GRAYED, 0, contextInfo.c_str()); } else { - AppendMenuW(subMenu, MF_STRING | MF_GRAYED, 0, L"No Unity project detected"); + AppendMenuW(subMenu, MF_STRING | MF_GRAYED, 0, L"No active creative tool detected"); } // Heartbeat summary @@ -390,7 +390,7 @@ void TrayIcon::UpdateContextMenu() } void TrayIcon::OpenGitHubRepository() { - const auto githubUrl = "https://github.com/Snow0406/Unity-Wakatime"; + const auto githubUrl = "https://github.com/Snow0406/creative-wakatime"; const std::wstring wGithubUrl(githubUrl, githubUrl + strlen(githubUrl)); WT_LOG("[TrayIcon] Opening GitHub repository: " << githubUrl); @@ -700,30 +700,35 @@ void TrayIcon::ShowBalloonNotification(const std::string &title, nid.uFlags &= ~NIF_INFO; } -void TrayIcon::SetCurrentProject(const std::string &projectName) +void TrayIcon::SetActiveContext(const std::string &contextName) { - const bool projectChanged = (currentProject != projectName); - currentProject = projectName; + const bool contextChanged = (activeContext != contextName); + activeContext = contextName; std::ostringstream tooltip; - tooltip << "Unity WakaTime"; - if (!projectName.empty()) + tooltip << "Creative WakaTime"; + if (!contextName.empty()) { - tooltip << " - " << projectName; + tooltip << " - " << contextName; } tooltip << " (" << totalHeartbeats << " heartbeats)"; UpdateTooltip(tooltip.str()); - if (projectChanged) + if (contextChanged) { RefreshStatusMenu(); } } +void TrayIcon::SetCurrentProject(const std::string &projectName) +{ + SetActiveContext(projectName); +} + void TrayIcon::IncrementHeartbeats() { totalHeartbeats++; - SetCurrentProject(currentProject); + SetActiveContext(activeContext); } void TrayIcon::SetMonitoringState(const bool monitoring) @@ -731,10 +736,10 @@ void TrayIcon::SetMonitoringState(const bool monitoring) isMonitoring = monitoring; std::ostringstream tooltip; - tooltip << "Unity WakaTime - " << (monitoring ? "Active" : "Paused"); - if (!currentProject.empty()) + tooltip << "Creative WakaTime - " << (monitoring ? "Active" : "Paused"); + if (!activeContext.empty()) { - tooltip << " - " << currentProject; + tooltip << " - " << activeContext; } UpdateTooltip(tooltip.str()); @@ -775,12 +780,12 @@ void TrayIcon::Shutdown() void TrayIcon::ShowErrorNotification(const std::string &message) { - ShowBalloonNotification("Unity WakaTime Error", message, 5000, NIIF_ERROR); + ShowBalloonNotification("Creative WakaTime Error", message, 5000, NIIF_ERROR); } void TrayIcon::ShowInfoNotification(const std::string &message) { - ShowBalloonNotification("Unity WakaTime", message, 2000, NIIF_INFO); + ShowBalloonNotification("Creative WakaTime", message, 2000, NIIF_INFO); } #pragma endregion Notification diff --git a/src/wakatime_client.cpp b/src/wakatime_client.cpp index 81b64ac..0ec8970 100644 --- a/src/wakatime_client.cpp +++ b/src/wakatime_client.cpp @@ -3,6 +3,7 @@ namespace { constexpr size_t kMaxHeartbeatQueueSize = 256; + constexpr size_t kMaxDebounceEntries = 256; constexpr auto kSameFileHeartbeatInterval = std::chrono::seconds(120); constexpr auto kSameFileWriteInterval = std::chrono::seconds(2); @@ -26,7 +27,7 @@ WakaTimeClient::WakaTimeClient() : hSession(nullptr), totalSent(0), totalFailed(0) { - userAgent = "unity-wakatime/1.0 (Windows)"; + userAgent = "creative-wakatime/" + Config::APP_VERSION + " (Windows)"; machineName = GetMachineName(); WT_LOG("[WakaTimeClient] Created for machine: " << machineName); @@ -282,7 +283,34 @@ std::string WakaTimeClient::Base64Encode(const std::string &input) return encoded; } -bool WakaTimeClient::SendHttpRequest(const std::string &jsonData) +std::string WakaTimeClient::BuildUserAgent(const HeartbeatData &heartbeat) const +{ + // WakaTime ๋Œ€์‹œ๋ณด๋“œ๋Š” User-Agent ๋์˜ plugin ํ† ํฐ์œผ๋กœ editor๋ฅผ ์‹๋ณ„ํ•œ๋‹ค. + // ํ˜•์‹: creative-wakatime/{ver} (Windows) {editor} {plugin}/{ver} + const std::string base = "creative-wakatime/" + Config::APP_VERSION + " (Windows) "; + + // editor ๋ฌธ์ž์—ด์—์„œ ์ฒซ ํ† ํฐ์œผ๋กœ ์–ด๋–ค ๋„๊ตฌ์ธ์ง€ ํŒ๋‹จ ("Unity 2022.3" โ†’ "Unity"). + std::string editorName = heartbeat.editor; + if (const size_t spacePos = editorName.find(' '); spacePos != std::string::npos) + { + editorName = editorName.substr(0, spacePos); + } + + std::string lowered = editorName; + std::transform(lowered.begin(), lowered.end(), lowered.begin(), + [](const unsigned char c) { return static_cast(::tolower(c)); }); + + if (lowered == "aseprite") + { + return base + "Aseprite aseprite-wakatime/" + Config::APP_VERSION; + } + + // ๊ธฐ๋ณธ๊ฐ’: Unity (editor ๋น„์–ด์žˆ์„ ๋•Œ ํฌํ•จ) + const std::string editorToken = heartbeat.editor.empty() ? "Unity" : heartbeat.editor; + return base + editorToken + " unity-wakatime/" + Config::APP_VERSION; +} + +bool WakaTimeClient::SendHttpRequest(const std::string &jsonData, const HeartbeatData &heartbeat) { if (!initialized || hSession == nullptr) { @@ -323,7 +351,7 @@ bool WakaTimeClient::SendHttpRequest(const std::string &jsonData) // HTTP ํ—ค๋” ์„ค์ • std::string authHeader = "Authorization: Basic " + Base64Encode(apiKey + ":"); std::string contentTypeHeader = "Content-Type: application/json"; - std::string userAgentHeader = "User-Agent: " + userAgent; + std::string userAgentHeader = "User-Agent: " + BuildUserAgent(heartbeat); std::string machineHeader = "X-Machine-Name: " + machineName; // ์™€์ด๋“œ ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•ด์„œ ํ—ค๋” ์ถ”๊ฐ€ @@ -429,7 +457,7 @@ void WakaTimeClient::SenderThreadFunction() } const std::string jsonData = HeartbeatToJson(heartbeat); - SendHttpRequest(jsonData); + SendHttpRequest(jsonData, heartbeat); // ์—ฐ์† ์ „์†ก ๋ ˆ์ดํŠธ๋ฆฌ๋ฐ‹. ์ข…๋ฃŒ ์‹œ ์ฆ‰์‹œ ๊นจ์–ด๋‚˜๋„๋ก wait_for ์‚ฌ์šฉ { @@ -449,7 +477,7 @@ void WakaTimeClient::SendHeartbeat(const std::string &filePath, const std::strin return; } - // HeartbeatData ์ƒ์„ฑ + // HeartbeatData ์ƒ์„ฑ (Unity ์ปจํ…์ŠคํŠธ) HeartbeatData heartbeat; heartbeat.entity = filePath; heartbeat.project = projectName; @@ -466,15 +494,28 @@ void WakaTimeClient::SendHeartbeat(const std::string &filePath, const std::strin heartbeat.editor = "Unity"; } + EnqueueHeartbeat(heartbeat); +} + +void WakaTimeClient::EnqueueHeartbeat(const HeartbeatData &heartbeat) +{ + if (!initialized) + { + WT_ERR("[WakaTimeClient] Not initialized, cannot enqueue heartbeat"); + return; + } + // ํ์— ์ถ”๊ฐ€ (๋น„๋™๊ธฐ ์ „์†ก) { std::lock_guard lock(queueMutex); const auto now = std::chrono::steady_clock::now(); - const bool isSameContext = (heartbeat.entity == lastQueuedEntity && heartbeat.project == lastQueuedProject); - if (lastQueuedAt.time_since_epoch().count() != 0 && isSameContext) + // entity + project๋ฅผ \x1f(unit separator)๋กœ ๊ฒฐํ•ฉํ•ด ์ปจํ…์ŠคํŠธ๋ณ„ ํ‚ค ์ƒ์„ฑ + const std::string key = heartbeat.entity + '\x1f' + heartbeat.project; + + if (const auto it = lastQueuedByEntity.find(key); it != lastQueuedByEntity.end()) { - const auto elapsed = now - lastQueuedAt; + const auto elapsed = now - it->second; const auto minInterval = heartbeat.is_write ? kSameFileWriteInterval : kSameFileHeartbeatInterval; if (elapsed < minInterval) { @@ -487,9 +528,35 @@ void WakaTimeClient::SendHeartbeat(const std::string &filePath, const std::strin heartbeatQueue.pop(); // ๋ฉ”๋ชจ๋ฆฌ ํญ์ฃผ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด ๊ฐ€์žฅ ์˜ค๋ž˜๋œ heartbeat ์ œ๊ฑฐ } heartbeatQueue.push(heartbeat); - lastQueuedAt = now; - lastQueuedEntity = heartbeat.entity; - lastQueuedProject = heartbeat.project; + lastQueuedByEntity[key] = now; + + // debounce ๋งต ๋ฌดํ•œ ์ฆ๊ฐ€ ๋ฐฉ์ง€: ์ƒํ•œ ์ดˆ๊ณผ ์‹œ ๋งŒ๋ฃŒ(>heartbeat interval)๋œ ์—”ํŠธ๋ฆฌ ์ •๋ฆฌ. + // ๊ทธ๋ž˜๋„ ์ƒํ•œ์„ ๋„˜์œผ๋ฉด ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ์—”ํŠธ๋ฆฌ๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค. + if (lastQueuedByEntity.size() > kMaxDebounceEntries) + { + for (auto it = lastQueuedByEntity.begin(); it != lastQueuedByEntity.end();) + { + if (now - it->second > kSameFileHeartbeatInterval) + { + it = lastQueuedByEntity.erase(it); + } + else + { + ++it; + } + } + + while (lastQueuedByEntity.size() > kMaxDebounceEntries) + { + auto oldest = lastQueuedByEntity.begin(); + for (auto it = lastQueuedByEntity.begin(); it != lastQueuedByEntity.end(); ++it) + { + if (it->second < oldest->second) oldest = it; + } + lastQueuedByEntity.erase(oldest); + } + } + queueCv.notify_one(); } } @@ -521,7 +588,12 @@ std::string WakaTimeClient::GetMaskedApiKey() const bool WakaTimeClient::LoadApiKeyFromFile() { - const std::string configPath = "wakatime_config.txt"; + const std::string configPath = Config::GetConfigFilePath(); + if (configPath.empty()) + { + WT_ERR("[WakaTimeClient] Failed to resolve config path (APPDATA missing)"); + return false; + } std::ifstream file(configPath); if (!file.is_open()) @@ -533,7 +605,9 @@ bool WakaTimeClient::LoadApiKeyFromFile() std::getline(file, apiKey); file.close(); - apiKey.erase(std::remove_if(apiKey.begin(), apiKey.end(), isspace), apiKey.end()); + apiKey.erase(std::remove_if(apiKey.begin(), apiKey.end(), + [](const unsigned char c) { return std::isspace(c) != 0; }), + apiKey.end()); if (apiKey.empty()) { @@ -547,7 +621,12 @@ bool WakaTimeClient::LoadApiKeyFromFile() bool WakaTimeClient::SaveApiKeyToFile(const std::string &key) { - const std::string configPath = "wakatime_config.txt"; + const std::string configPath = Config::GetConfigFilePath(); + if (configPath.empty()) + { + WT_ERR("[WakaTimeClient] Failed to resolve config path (APPDATA missing)"); + return false; + } std::ofstream file(configPath); if (!file.is_open())