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)
-
+
-๐ฏ 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())