claude-ai-office 0.23.7 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,210 @@
1
+ # AI Office
2
+
3
+ [한국어](README.md) · English
4
+
5
+ An isometric office that shows what your Claude Code sessions are doing, in real time. Every Claude Code session in your terminal sends hook events; each session becomes a character that walks around the office. Reading a file sends them to the **Library**, running a command puts them in front of the **Server Room** racks, thinking keeps them at their desk **pondering**, and delegating work makes a subagent arrive by **Elevator**. Each git project gets its own floor, and every session of that project shares the floor. A session waiting for your answer raises a hand at **My Desk**, and you also get a browser notification, a chime and a tab-title badge.
6
+
7
+ ![AI Office overview](docs/screenshots/fe-30-overview.jpg)
8
+
9
+ ## Install
10
+
11
+ **macOS (Apple Silicon), as an app:** ⬇️ **[AI-Office-mac-arm64.dmg](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-mac-arm64.dmg)** (about 40 MB, Node bundled, always the latest), or `brew install --cask cozyuj/tap/ai-office`. Drag `AI Office.app` to Applications and **right-click → Open** the first time. See [Menu bar app](#menu-bar-app-tauri) below.
12
+
13
+ **Windows (x64), as an app:** ⬇️ **[AI-Office-windows-x64-setup.exe](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-windows-x64-setup.exe)**. If SmartScreen warns, choose "More info → Run anyway".
14
+
15
+ **Everything else (Intel Mac · Linux · any OS with Node 18+), the web version:**
16
+
17
+ ```bash
18
+ npx claude-ai-office
19
+ ```
20
+
21
+ (Straight from GitHub instead of npm: `npx github:cozyuj/AI-office`, same behavior.)
22
+
23
+ That one line (1) copies the app to `~/.ai-office/app`, (2) registers hooks in `~/.claude/settings.json`, then (3) starts the server and opens your browser. Run the same command again later to start the server; it also updates the installed copy when the package changed. Hooks apply to Claude Code sessions **started after** installation.
24
+
25
+ ### `ai-office` subcommands
26
+
27
+ | Command | What it does |
28
+ |---|---|
29
+ | `npx claude-ai-office` | `up` = install + install-hooks + start + open browser |
30
+ | `… install [--force]` | Copy the app to `~/.ai-office/app` (a stable path independent of the npx cache) |
31
+ | `… install-hooks` | Register hooks in `~/.claude/settings.json` (idempotent, backs up the file) |
32
+ | `… uninstall-hooks` | Remove the hooks |
33
+ | `… start [--port N] [--no-open]` | Start only the server; if one is already running, open it |
34
+ | `… demo` | Fill the office with fake sessions (same as ⚙️ Settings → Demo) |
35
+ | `… doctor` | Check Node version, hooks, install, port, journal size and hook port |
36
+ | `… compact` | Compact the event journal right now (the server also does this hourly) |
37
+
38
+ ### Hooks
39
+
40
+ `scripts/install-hooks.js` registers `node hooks/emit.js` for Claude Code's hook events (SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, Notification, Stop, SubagentStart, SubagentStop, PreCompact). It needs only Node, no shell or curl (Windows included), and gives up silently within one second when the server is down, so Claude is never blocked.
41
+
42
+ ### Port
43
+
44
+ The default port is `4777`. To change it, the server and the hooks must agree:
45
+
46
+ ```bash
47
+ npx claude-ai-office install-hooks --port 4800 # hooks → 4800
48
+ npx claude-ai-office start --port 4800 # server → 4800
49
+ ```
50
+
51
+ A mismatch shows a yellow banner in the UI and in the `hook port` line of `doctor`. From a clone:
52
+
53
+ ```bash
54
+ git clone https://github.com/cozyuj/AI-office.git && cd AI-office
55
+ node bin/ai-office.js # or: npm run install-hooks && npm start
56
+ ```
57
+
58
+ ## The screen
59
+
60
+ ![Café and terrace](docs/screenshots/fe-30-cafe-terrace.jpg)
61
+
62
+ | Area | Contents |
63
+ |---|---|
64
+ | **Left: Floors** | One floor per git project (repository root). Worktrees share the floor and show their branch (⎇). Opening a floor lists its sessions with `waiting` and `viewing` badges. 🎨 on the selected floor picks that floor's interior preset (Warm Wood · Cool Gray · Mint · Lavender · Sand · Pastel; assigned automatically by default). "Follow the latest session" (`A`) moves the selection to the most recently active session; when it is off, the last floor and session you looked at are restored next time |
65
+ | **Center: Office** | Drag to pan, wheel or buttons to zoom, `⟲` to reset, pinch on touch screens. Click a character to select its session; hover for a tooltip (current action, target file, prompt, branch, path). 📍 **Follow** (`F`) keeps the camera on the selected session's Claude. 🏢 **Building view** (`O`) appears when there are two or more floors |
66
+ | **Right panel** | Session card (project path · session id · character name · copy the resume command · 🗑 dismiss ended sessions), people in the office (room · speech bubble · elapsed time), activity log (search with `/`, selected session only), legend (click the title to collapse) |
67
+
68
+ **Resume** on the session card copies `cd "<project folder>" && claude --resume <session id>` to the clipboard, so you can continue a session from the terminal. Sessions idle for two hours are retired automatically; **🗑 Dismiss** removes a finished session from the list right away.
69
+
70
+ ### Who goes where
71
+
72
+ | Tool | Room | Speech bubble |
73
+ |---|---|---|
74
+ | Read, Grep, Glob | 📖 Library | `reading server.js`, `searching "TODO"` |
75
+ | Edit, Write | ✏️ Dev Room | `editing app.js` |
76
+ | Bash | 🖥️ Server Room | `running npm test` |
77
+ | WebSearch, WebFetch, MCP tools | 🌐 Research Room | `searching "tauri vs electron"` |
78
+ | Agent, TodoWrite, Skill, Plan | 🤝 Meeting Room | `delegating: find API endpoints` |
79
+ | AskUserQuestion, permission prompts | 🙋 My Desk | `I have a question`, `need permission` |
80
+ | Stop (end of turn) | ☕ Lounge · Café · Terrace (one rest room per session) | `resting`, `having coffee`, `getting some air` |
81
+ | SubagentStart / Stop | 🛗 Elevator | arriving / leaving |
82
+
83
+ Each session gets one rest room, chosen from a hash of its session id, so every browser sees the same session in the same room. The café (a glass-walled room with window seats) and the terrace (deck, parasols, benches) are places to rest, not rooms tied to a tool. With **Liveliness** on (⚙️ Settings), resting characters wander between spots in their room (counter, window, table, parasol, bench, railing, sofa, vending machine) every 10–30 seconds with bubbles like `☕ a latte`, `🪟 looking outside`, `🌤️ nice breeze`; with it off they stay put.
84
+
85
+ ### A different office on every floor
86
+
87
+ Each floor (= project) is assigned an **interior preset** automatically (hash of the floor path, avoiding its neighbours). A preset sets not only the palette but the **layout**: the nine work rooms (Library, Dev Room, Research Room, Meeting Room, Lounge, Server Room, My Desk, Lobby, Elevator) sit in different places, and the two rest facilities change.
88
+
89
+ | Preset | Facilities |
90
+ |---|---|
91
+ | 🪵 Warm Wood · 🍬 Pastel | Café · Terrace |
92
+ | 🩶 Cool Gray | Seminar Room · Studio |
93
+ | 🌿 Mint | Gym · Rooftop Garden |
94
+ | 💜 Lavender | Book Lounge · Game Room |
95
+ | 🏜️ Sand | Canteen · Nap Room |
96
+
97
+ A session's rest room (café or terrace) becomes that floor's facility: on a Mint floor the character goes to the Gym (`🏃 working out`) and the log reads `✅ … → 🏃 Gym`. Press **🎨** on the selected floor to pick a preset by hand; the choice is stored on the server (`~/.ai-office/floor-styles.json`) so every browser sees the same floor (`Auto` restores the automatic choice). Building view shows the different layouts too.
98
+
99
+ ![Different office on every floor](docs/screenshots/fe-35-overview.jpg)
100
+
101
+ Subagents wear blue hard hats and are named after the `description` given to the Agent tool. You (`me`) sit at My Desk, and Claude comes over when it has a question or needs permission.
102
+
103
+ ## Settings and shortcuts
104
+
105
+ Open **⚙️ Settings** in the header. Everything here is stored in this browser only (localStorage).
106
+
107
+ | Setting | Meaning | Key |
108
+ |---|---|---|
109
+ | Bubbles | Show speech bubbles for **all** sessions or only the **selected** one | `B` |
110
+ | Browser notifications | OS notification when Claude asks a question or waits for permission. Asks for browser permission the first time | |
111
+ | Waiting chime | A short sound when a session starts waiting for you. Click once so the browser allows audio | `S` |
112
+ | Sound scope | **Waiting only** (high two-tone) or **also failures and session end** (low two-tone) | |
113
+ | Liveliness | Resting characters wander and idle animations | |
114
+ | Theme | Light / dark | `T` |
115
+ | Demo | Four fake sessions on separate `(demo)` floors | `D` |
116
+ | Character looks | Opens the customizer (below) | |
117
+ | Daylight | Window, terrace and room lighting follow the real clock: morning (06–10), day (10–17), evening (17–20), night (20–06). Can be fixed to one period or turned off | |
118
+ | Floor interior | Interior preset of the floor you are viewing | |
119
+ | Language | 한국어 / English | |
120
+ | Shortcuts | Keyboard shortcut panel | `?` |
121
+
122
+ Shortcuts work when no input field has focus.
123
+
124
+ | Key | Action |
125
+ |---|---|
126
+ | `0` – `9` | Go to floor number (7 → 7F; for 12F press `1` then `2`) |
127
+ | `←` `→` (or `↑` `↓`) | Previous / next floor |
128
+ | `F` | 📍 Follow the selected session's Claude |
129
+ | `A` | Follow the latest session automatically |
130
+ | `O` | 🏢 Building view |
131
+ | `D` | Demo on/off |
132
+ | `B` | Bubbles: all / selected |
133
+ | `S` | Waiting chime |
134
+ | `T` | Light / dark theme |
135
+ | `/` | Focus the log search |
136
+ | `Esc` | Close shortcut panel · customizer · settings · building view → clear search → stop following (one step at a time) |
137
+ | `?` | Shortcut panel |
138
+
139
+ ## Character looks and names
140
+
141
+ ![Customizer](docs/screenshots/fe-30-customize.jpg)
142
+
143
+ ⚙️ Settings → **Character looks** customizes four targets separately: **me / default Claude / default subagent / the Claude of the session you are viewing**.
144
+
145
+ - **10 presets**: Developer · Suit · Researcher · Casual · Artist · Wizard · King · Robot · Cat · Bear. Pick one, then tweak the parts.
146
+ - **4 body types**: human · robot · cat · bear. The skin (fur, metal) palette follows the body.
147
+ - **Parts**: skin, hair (12 styles + color), top (8 styles + color; `auto` uses the session color), bottom (pants · shorts · skirt), shoes, glasses (round · square · sunglasses · goggles · monocle), hats (cap · beanie · beret · headband · bucket hat · top hat · wizard · crown …), extras (headphones · scarf · badge · earring · bow tie · beard · mask …).
148
+ - 🎲 random, ↺ reset, 🗑 delete the saved look. Saved in this browser only.
149
+
150
+ **Names per session**: every session's character gets an automatic nickname (Alex, Sam, Max, Leo, Mia, Zoe, …). Rename it in the "Character" row of the session card or in the customizer (🎲 draws another nickname; clearing the field goes back to automatic). The log and tooltips use that name. The server still calls it `Claude`; only the screen changes.
151
+
152
+ ## Building view
153
+
154
+ ![Building view](docs/screenshots/fe-30-building.jpg)
155
+
156
+ With two or more floors, 🏢 **Building view** (`O`) appears in the header. Floors are stacked like a building, each with a mini office, character dots and the session count and status (`waiting` / `working` / `thinking` / `quiet`). The interior preset of each floor shows in its mini office. Click a floor to enter it; `Esc` or the same button closes the view.
157
+
158
+ ## Menu bar app (Tauri)
159
+
160
+ A resident app that shows the web UI in a window. It starts the Node server itself (or reuses one that is already running) and lives in the menu bar / system tray with Open, Hide, Always on top, Reinstall hooks, Check for updates and Quit. When sessions are waiting for you, the menu bar shows `🙋 n`. Closing the window hides it; Quit stops the server too. Node is bundled: on first launch the app copies the server files to `~/.ai-office/app` and the Node binary to `~/.ai-office/node`, then registers the hooks, so moving or deleting the app does not break them.
161
+
162
+ **Updates**: the app checks for a new version 10 seconds after launch and every 6 hours, downloads it in the background, then shows ⬆ in the menu bar and enables "Apply update (restart)". Releases are built by GitHub Actions for macOS (Apple Silicon) and Windows (x64) and signed for the updater.
163
+
164
+ > **Download**: [macOS AI-Office-mac-arm64.dmg](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-mac-arm64.dmg) · [Windows AI-Office-windows-x64-setup.exe](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-windows-x64-setup.exe) (always the latest; older versions in [Releases](https://github.com/cozyuj/AI-office/releases)). The macOS app is not notarized: **right-click → Open** the first time; on macOS 15+ use System Settings → Privacy & Security → **Open Anyway**, and if it still says "damaged", run `xattr -dr com.apple.quarantine "/Applications/AI Office.app"` once. To build it yourself:
165
+
166
+ ```bash
167
+ npm run app:dev # dev run (Rust toolchain + Xcode CLT / MSVC build tools, first build takes minutes)
168
+ npm run app:build # .app / .dmg / .exe under app/src-tauri/target/release/bundle/
169
+ ```
170
+
171
+ `scripts/fetch-node.js` downloads the Node LTS binary and bundles it as a sidecar; the server files are app resources. A dev run without the bundle falls back to the system `node` and the repository's `server.js`.
172
+
173
+ ## Architecture
174
+
175
+ ```
176
+ Claude Code session ─hooks─▶ hooks/emit.js ─POST /hook─▶ server.js ─SSE /events─▶ public/ (canvas)
177
+ ```
178
+
179
+ - `server.js` receives hooks, applies the state machine, relays SSE, serves static files, starts/stops the demo (`/api/demo/start|stop`), dismisses sessions (`DELETE /api/sessions/:id`)
180
+ - `lib/normalize.js` slims hook payloads and maps tools to rooms and bubbles
181
+ - `lib/state.js` floor/session/character state machine, `lib/project.js` cwd → git repository, branch, worktree
182
+ - `lib/journal.js` JSONL event journal (`~/.ai-office/events.jsonl`), replayed on start, compacted hourly
183
+ - `public/js/scene/*` isometric map, furniture, characters (`avatar.js`), A*, camera, daylight (`daylight.js`), floor styles (`floorStyle.js`)
184
+ - `public/js/overview.js` building view, `customize.js` customizer, `names.js` per-session names, `panels.js` side panels, `i18n.js` language
185
+ - `bin/ai-office.js` CLI, `scripts/install-hooks.js` hook registration, `scripts/demo.js` demo
186
+
187
+ Zero dependencies, no bundler. Design notes: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) (Korean), technical overview: [docs/AI-Office-소개.html](docs/AI-Office-소개.html) (Korean).
188
+
189
+ ## Development
190
+
191
+ ```bash
192
+ npm test # node --test (state machine · pathfinding · looks · names · CLI · server · smoke …)
193
+ AI_OFFICE_PORT=4799 npm start # another port (hooks must be registered for the same port)
194
+ npm run demo # push fake sessions into a running server (DEMO_SPEED=3 for faster)
195
+ ```
196
+
197
+ Versioning: bug fixes and docs bump patch, features bump minor, breaking changes bump major. One command updates all four files (package.json, app/package.json, tauri.conf.json, Cargo.toml):
198
+
199
+ ```bash
200
+ npm run bump:patch # 0.24.0 → 0.24.1
201
+ npm run bump:minor # 0.24.0 → 0.25.0
202
+ ```
203
+
204
+ Releases: push a tag `vX.Y.Z` and GitHub Actions builds the macOS and Windows apps, uploads them to the release with the updater manifest and stable-named copies, then installs and launches the Windows build on a runner to check the server and hooks. Tests run on Ubuntu, Windows and macOS on every push and pull request.
205
+
206
+ Workflow: create a GitHub issue named `[FE-30]`, `[BE-13]`, `[APP-03]` (FE/BE/APP), branch from `main` as `feature/FE-30`, and report results, tests and screenshots as issue comments.
207
+
208
+ ## License
209
+
210
+ MIT
package/README.md CHANGED
@@ -1,14 +1,18 @@
1
1
  # AI Office
2
2
 
3
+ 한국어 · [English](README.en.md)
4
+
3
5
  Claude Code 가 터미널에서 뭘 하고 있는지 한눈에 보려고 차린 사무실. 내 터미널의 Claude Code 세션들이 훅(hook) 이벤트를 보내면, 세션마다 캐릭터 한 명이 아이소메트릭 사무실에서 실시간으로 움직인다. 파일을 읽을 땐 **자료실** 가서 책을 펴고, 명령을 돌릴 땐 **서버실** 랙 앞에 서 있고, 막히면 그 자리에서 턱 괴고 **고민 중**, 일을 나눠 맡기면 서브에이전트가 **엘리베이터** 타고 출근한다. git 프로젝트마다 층이 하나씩 생기고, 한 층에 그 프로젝트의 세션 전부가 보인다. 답을 기다리는 세션은 **내 자리** 에서 손을 들고, 브라우저 알림·소리·탭 제목으로도 알려 준다.
4
6
 
5
7
  ![AI Office 전체 화면](docs/screenshots/fe-30-overview.jpg)
6
8
 
7
9
  ## 설치
8
10
 
9
- **맥(Apple Silicon)은 앱으로:** ⬇️ **[AI Office_0.23.4_aarch64.dmg 받기](https://github.com/cozyuj/AI-office/releases/download/v0.23.4/AI-Office_0.23.4_aarch64.dmg)** (39MB, Node 내장 · 다른 버전은 [Releases](https://github.com/cozyuj/AI-office/releases/latest)). 받은 뒤 `AI Office.app` 을 응용 프로그램 폴더에 넣고 처음 한 번 **우클릭 → 열기**. 자세한 건 아래 [맥 메뉴바 앱](#맥-메뉴바-앱-tauri).
11
+ **맥(Apple Silicon)은 앱으로:** ⬇️ **[AI-Office-mac-arm64.dmg 받기](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-mac-arm64.dmg)** (약 40MB, Node 내장, 항상 최신 버전). 받은 뒤 `AI Office.app` 을 응용 프로그램 폴더에 넣고 처음 한 번 **우클릭 → 열기**. Homebrew 로는 `brew install --cask cozyuj/tap/ai-office`. 자세한 건 아래 [메뉴바 앱](#메뉴바-앱-tauri).
12
+
13
+ **Windows(x64)도 앱으로:** ⬇️ **[AI-Office-windows-x64-setup.exe 받기](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-windows-x64-setup.exe)**. SmartScreen 경고가 뜨면 "추가 정보 → 실행".
10
14
 
11
- **그 외(Intel 맥 · Windows · Linux)는 웹 버전으로:** Node 18 이상만 있으면 공통 한 줄:
15
+ **그 외(Intel 맥 · Linux 등)는 웹 버전으로:** Node 18 이상만 있으면 공통 한 줄:
12
16
 
13
17
  ```bash
14
18
  npx claude-ai-office
@@ -31,6 +35,7 @@ npx claude-ai-office
31
35
  | `… start [--port N] [--no-open]` | 서버만 기동. 이미 떠 있으면 그 주소를 연다 |
32
36
  | `… demo` | 가짜 세션으로 사무실을 북적이게 (화면의 ⚙️ 설정 → 데모와 같음) |
33
37
  | `… doctor` | Node 버전·훅 등록 수·설치본·포트·저널·훅 포트 점검 |
38
+ | `… compact` | 이벤트 저널 즉시 압축 (서버도 매시간 자동) |
34
39
 
35
40
  ### 훅
36
41
 
@@ -111,6 +116,8 @@ node bin/ai-office.js # 또는 npm run install-hooks && npm start
111
116
  | 테마 | 라이트 / 다크 | `T` |
112
117
  | 데모 | 가짜 세션 4개를 띄워 사무실을 북적이게. 실제 세션과 별도 층이고 `(데모)` 가 붙는다 | `D` |
113
118
  | 캐릭터 외형 | 외형 모달 열기 (아래 참고) | |
119
+ | 층 인테리어 | 지금 보는 층의 인테리어 프리셋 (층 행의 🎨 와 같음) | |
120
+ | 언어 | 한국어 / English. 화면 문구·캔버스 글자·알림이 바뀐다. 세션 제목·프롬프트·Claude 메시지는 쓴 그대로 | |
114
121
  | 시간대 조명 | 실제 시각에 따라 창문·테라스·실내 빛이 아침(06~10)·낮(10~17)·저녁(17~20)·밤(20~06)으로 바뀐다. `자동` 외에 `끔` 이나 시간대 하나로 고정 가능 | |
115
122
  | 단축키 | 키보드 단축키 표 패널 | `?` |
116
123
 
@@ -150,18 +157,20 @@ node bin/ai-office.js # 또는 npm run install-hooks && npm start
150
157
 
151
158
  층이 둘 이상이면 헤더에 🏢 **전체 보기**(`O`)가 나타납니다. 층들을 건물처럼 세로로 쌓아, 층마다 미니 사무실 위에 캐릭터 점과 세션 수·상태(`답 기다림` / `작업 중` / `생각 중` / `조용함`)를 보여 줍니다. 층마다 고른 인테리어 스타일이 미니 사무실에도 반영됩니다. 층을 클릭하면 그 층 사무실로 들어갑니다. `Esc` 또는 같은 버튼으로 닫습니다.
152
159
 
153
- ## 맥 메뉴바 앱 (Tauri)
160
+ ## 메뉴바 앱 (Tauri)
154
161
 
155
- 웹 버전을 그대로 창에 담은 상주 앱입니다. 앱이 Node 서버를 직접 띄우고(이미 떠 있으면 재사용) 메뉴바 아이콘에서 열기/숨기기/항상 위/종료를 합니다. 답을 기다리는 세션이 있으면 메뉴바에 `🙋 n` 이 뜹니다. 창을 닫아도 메뉴바에 남고, 종료하면 서버도 함께 내려갑니다. 앱에는 Node 가 내장되어 있고, 첫 실행 때 서버 파일을 `~/.ai-office/app` 에 복사하고 훅을 등록합니다.
162
+ 웹 버전을 그대로 창에 담은 상주 앱입니다(맥 메뉴바 · Windows 트레이). 앱이 Node 서버를 직접 띄우고(이미 떠 있으면 재사용) 아이콘 메뉴에서 열기/숨기기/항상 위/훅 다시 설치/업데이트 확인/종료를 합니다. 답을 기다리는 세션이 있으면 메뉴바에 `🙋 n` 이 뜹니다. 창을 닫아도 메뉴바에 남고, 종료하면 서버도 함께 내려갑니다(앱이 강제 종료돼도 서버가 스스로 끝납니다). 앱에는 Node 가 내장되어 있고, 첫 실행 때 서버 파일을 `~/.ai-office/app` 에, Node 를 `~/.ai-office/node` 에 복사하고 그 경로로 훅을 등록하므로 앱을 옮기거나 지워도 훅이 깨지지 않습니다.
156
163
 
157
- > **다운로드**: ⬇️ **[AI Office_0.23.4_aarch64.dmg](https://github.com/cozyuj/AI-office/releases/download/v0.23.4/AI-Office_0.23.4_aarch64.dmg)** (Apple Silicon, 39MB) · 다른 버전은 [Releases](https://github.com/cozyuj/AI-office/releases/latest). DMG 를 열어 `AI Office.app` 을 응용 프로그램 폴더에 넣으세요. 코드 서명이 없어 처음엔 "확인되지 않은 개발자" 경고가 뜨니 앱을 **우클릭 → 열기** 로 한 번 허용하고, macOS 15 이상에서 안 열리면 시스템 설정 → 개인정보 보호 및 보안 → **그래도 열기**, 그래도 "손상됨" 이라고 나오면 터미널에서 `xattr -dr com.apple.quarantine "/Applications/AI Office.app"` 를 한 번 실행하세요. Intel 맥·Windows 는 아직 빌드하지 않으니 위의 `npx claude-ai-office` 한 줄로 웹 버전을 쓰세요. 직접 빌드하려면:
164
+ **자동 업데이트**: 앱이 시작 10초 뒤와 6시간마다 새 버전을 확인해 백그라운드로 받아 두고, 메뉴바에 ⬆ 를 띄우며 메뉴의 "업데이트 적용 (다시 시작)" 이 켜집니다. 릴리스는 태그를 올리면 GitHub Actions 가 맥(Apple Silicon)·Windows(x64) 로 빌드하고 서명해 올립니다.
165
+
166
+ > **다운로드**: ⬇️ **[맥 AI-Office-mac-arm64.dmg](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-mac-arm64.dmg)** · **[Windows AI-Office-windows-x64-setup.exe](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-windows-x64-setup.exe)** (항상 최신) · 이전 버전은 [Releases](https://github.com/cozyuj/AI-office/releases). DMG 를 열어 `AI Office.app` 을 응용 프로그램 폴더에 넣으세요. 코드 서명이 없어 처음엔 "확인되지 않은 개발자" 경고가 뜨니 앱을 **우클릭 → 열기** 로 한 번 허용하고, macOS 15 이상에서 안 열리면 시스템 설정 → 개인정보 보호 및 보안 → **그래도 열기**, 그래도 "손상됨" 이라고 나오면 터미널에서 `xattr -dr com.apple.quarantine "/Applications/AI Office.app"` 를 한 번 실행하세요. Intel 맥·Linux 는 위의 `npx claude-ai-office` 한 줄로 웹 버전을 쓰세요. 직접 빌드하려면:
158
167
 
159
168
  ```bash
160
169
  npm run app:dev # 개발 실행 (Rust 툴체인 + Xcode CLT 필요, 첫 빌드 수 분)
161
170
  npm run app:build # app/src-tauri/target/release/bundle/ 에 .app / .dmg
162
171
  ```
163
172
 
164
- 빌드 시 `scripts/fetch-node.sh` 가 Node LTS 바이너리를 내려받아 사이드카로 번들하고, 서버 파일은 앱 리소스로 포함됩니다. 개발 실행(레포에서)은 번들이 없으면 시스템 `node` 와 레포의 `server.js` 를 씁니다.
173
+ 빌드 시 `scripts/fetch-node.js` 가 Node LTS 바이너리(맥·리눅스·Windows)를 내려받아 사이드카로 번들하고, 서버 파일은 앱 리소스로 포함됩니다. 개발 실행(레포에서)은 번들이 없으면 시스템 `node` 와 레포의 `server.js` 를 씁니다.
165
174
 
166
175
  ## 구조
167
176
 
@@ -194,4 +203,6 @@ npm run bump:patch # 0.19.0 → 0.19.1
194
203
  npm run bump:minor # 0.19.0 → 0.20.0
195
204
  ```
196
205
 
206
+ 릴리스: `git tag vX.Y.Z && git push origin vX.Y.Z` 를 올리면 GitHub Actions 가 맥·Windows 앱을 빌드해 Release 에 올리고(updater 용 `latest.json` 포함), Windows 러너에서 설치 → 실행 → 훅까지 확인합니다. 테스트는 push·PR 마다 Ubuntu·Windows·macOS 에서 돕니다.
207
+
197
208
  작업 흐름: GitHub 이슈를 `[FE-30]`, `[BE-13]`, `[APP-03]` 처럼 FE/BE/APP 으로 나눠 만들고, `main` 에서 `feature/FE-30` 식 브랜치를 분리해 작업한 뒤 결과·테스트·캡처를 이슈 댓글로 남긴다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-ai-office",
3
- "version": "0.23.7",
3
+ "version": "0.25.1",
4
4
  "description": "Claude Code 세션 활동을 아이소메트릭 사무실로 실시간 시각화",
5
5
  "keywords": [
6
6
  "claude",
@@ -45,8 +45,8 @@
45
45
  "uninstall-hooks": "node scripts/install-hooks.js --remove",
46
46
  "test": "node --test test/*.test.js",
47
47
  "cli": "node bin/ai-office.js",
48
- "app:dev": "sh scripts/fetch-node.sh && npm --prefix app run dev",
49
- "app:build": "sh scripts/fetch-node.sh && npm --prefix app run build",
48
+ "app:dev": "node scripts/fetch-node.js && npm --prefix app run dev",
49
+ "app:build": "node scripts/fetch-node.js && npm --prefix app run build",
50
50
  "app:icon": "npm --prefix app run icon",
51
51
  "bump:patch": "node scripts/bump-version.js patch",
52
52
  "bump:minor": "node scripts/bump-version.js minor",
package/public/index.html CHANGED
@@ -42,6 +42,7 @@
42
42
  <div class="settings-row"><span class="settings-label">캐릭터 외형</span><button id="customize-btn" class="tool" title="나 / Claude / 서브에이전트 캐릭터 외형 고르기 — 프리셋·몸 종류(사람·로봇·고양이·곰)·파츠, 지금 보는 세션의 캐릭터 이름">👤 고르기…</button></div>
43
43
  <div class="settings-row"><span class="settings-label">시간대 조명</span><select id="daylight-select" class="tool" title="실제 시각에 따라 창문·테라스·실내 빛이 바뀝니다 (아침 06~10 · 낮 10~17 · 저녁 17~20 · 밤 20~06). 미리보기로 시간대를 고정할 수도 있어요"><option value="auto">🕒 자동 (실제 시각)</option><option value="off">끔</option><option value="morning">🌅 아침</option><option value="day">☀️ 낮</option><option value="evening">🌇 저녁</option><option value="night">🌙 밤</option></select></div>
44
44
  <div class="settings-row"><span class="settings-label">층 인테리어</span><button id="floor-style-btn" class="tool" title="지금 보는 층의 인테리어 프리셋 (층별 안내의 🎨 와 같음) — 색과 사무실 구조가 바뀝니다">🎨 고르기…</button></div>
45
+ <div class="settings-row"><span class="settings-label">언어</span><select id="lang-select" class="tool" title="화면 언어"><option value="ko">한국어</option><option value="en">English</option></select></div>
45
46
  <div class="settings-row"><span class="settings-label">단축키 <kbd>?</kbd></span><button id="keys-btn" class="tool" title="키보드 단축키 표">⌨️ 보기…</button></div>
46
47
  </div>
47
48
  </div>
@@ -0,0 +1,200 @@
1
+ // 언어 (FE-45): 한국어가 원문이고 영어는 번역이다.
2
+ // - tr(text): 캔버스·코드에서 만든 문자열을 번역한다 (사전 → 패턴 규칙 → 구절 치환). 한국어 모드면 그대로.
3
+ // - DOM 은 MutationObserver 가 텍스트 노드·title·placeholder 를 자동으로 바꾼다 (원문은 WeakMap 에 보관해 되돌릴 수 있다).
4
+ // 서버가 보내는 말풍선·로그도 한국어라 같은 규칙으로 번역한다.
5
+ const KEY = 'aiOffice.lang';
6
+ export const LANGS = [['ko', '한국어'], ['en', 'English']];
7
+ let lang = (() => {
8
+ try { const v = localStorage.getItem(KEY); if (v === 'ko' || v === 'en') return v; } catch {}
9
+ return (typeof navigator !== 'undefined' && /^ko/i.test(navigator.language || '')) ? 'ko' : 'en';
10
+ })();
11
+ export function getLang() { return lang; }
12
+ const listeners = new Set();
13
+ export function onLangChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
14
+
15
+ // ---------- 사전 (정확히 일치) ----------
16
+ const DICT = {
17
+ // 방·시설
18
+ '자료실': 'Library', '개발실': 'Dev Room', '정보검색실': 'Research Room', '회의실': 'Meeting Room', '휴게실': 'Lounge', '서버실': 'Server Room',
19
+ '내 자리': 'My Desk', '로비': 'Lobby', '엘리베이터': 'Elevator', '카페': 'Café', '테라스': 'Terrace', '복도': 'Corridor',
20
+ '세미나실': 'Seminar Room', '스튜디오': 'Studio', '헬스장': 'Gym', '옥상정원': 'Rooftop Garden', '북 라운지': 'Book Lounge', '게임룸': 'Game Room', '구내식당': 'Canteen', '수면실': 'Nap Room',
21
+ '카페 · 테라스': 'Café · Terrace', '세미나실 · 스튜디오': 'Seminar Room · Studio', '헬스장 · 옥상정원': 'Gym · Rooftop Garden', '북 라운지 · 게임룸': 'Book Lounge · Game Room', '구내식당 · 수면실': 'Canteen · Nap Room',
22
+ '📖 자료실': '📖 Library', '✏️ 개발실': '✏️ Dev Room', '🖥️ 서버실': '🖥️ Server Room', '🌐 정보검색실': '🌐 Research Room', '🤝 회의실': '🤝 Meeting Room', '☕ 휴게실': '☕ Lounge', '🙋 내 자리': '🙋 My Desk', '🛗 엘리베이터': '🛗 Elevator',
23
+ '파일 읽기 · 검색': 'reading · searching files', '코드 작성 · 수정': 'writing · editing code', '명령 실행 · git': 'commands · git', '웹 · 외부 도구': 'web · external tools', '계획 · 일 나누기': 'planning · delegating', '휴식 · 대기': 'resting · idle', '질문 · 허락 요청': 'questions · permission', '서브에이전트 출퇴근': 'subagents in and out',
24
+ // 상태·말풍선 (서버)
25
+ '고민 중…': 'thinking…', '고민 중': 'thinking', '생각 중': 'thinking', '작업 중': 'working', '답 기다리는 중': 'waiting for you', '답 기다림': 'waiting', '대기': 'idle', '조용함': 'quiet', '자리 비움': 'away', '퇴근': 'left', '휴식 중': 'resting',
26
+ '커피 마시는 중': 'having coffee', '바람 쐬는 중': 'getting some air', '밥 먹는 중': 'eating', '낮잠 자는 중': 'napping', '운동 중': 'working out', '정원 산책 중': 'strolling the garden', '책 읽는 중': 'reading a book', '게임 한 판': 'a quick game', '녹음 중': 'recording', '세미나 듣는 중': 'at a seminar',
27
+ '출근했습니다': 'arrived', '퇴근합니다': 'leaving', '요청 받음': 'request received', '입력 요청': 'input requested', '입력이 필요해요': 'input needed', '질문 있어요': 'I have a question', '허락이 필요해요': 'need permission',
28
+ '할 일 기다리는 중': 'waiting for work', '기억 정리 중': 'compacting memory', '실패했네, 다시 생각 중': 'failed, thinking again', '알림 확인 중': 'checking a notification', '작업 완료': 'done', '자리 비움 (30분 무활동)': 'away (30 min idle)', '터미널 종료 추정': 'terminal probably closed',
29
+ '할 일 정리 중': 'organizing tasks', '계획 세우는 중': 'planning', '서브에이전트': 'subagent', '부': 'sub', '제목 없는 세션': 'untitled session', '사무실': 'Office', '나': 'me',
30
+ '☕ 라떼 한 잔': '☕ a latte', '🪟 창밖 구경': '🪟 looking outside', '🌤️ 바람 좋다': '🌤️ nice breeze', '커피 한 잔': 'a cup of coffee', '소파에서 잠깐': 'a moment on the sofa', '화분 구경': 'admiring plants', '창밖 구경': 'looking outside', '로비 산책': 'walking the lobby', '벤치에서 한숨': 'a sigh on the bench', '카페에서 라떼': 'a latte at the café', '창가 자리': 'window seat', '카페에서 수다': 'chatting at the café', '테라스 산책': 'a walk on the terrace', '벤치에서 햇볕': 'sun on the bench', '라떼 한 잔': 'a latte', '바람 좋다': 'nice breeze',
31
+ // 헤더·패널
32
+ '층별 안내': 'Floors', 'FLOOR GUIDE': 'FLOOR GUIDE', '사무실 사람들': 'People', '활동 로그': 'Activity log', '어디서 무엇을 하나': 'Who goes where', '세션': 'Session', '프로젝트': 'Project', '캐릭터': 'Character', '이어가기': 'Resume', '복사': 'Copy', '복사됨': 'Copied', '복사 실패': 'Copy failed',
33
+ '훅 이벤트를 기다리는 중': 'waiting for hook events', '세션을 선택하세요': 'select a session', '아직 출근한 사람이 없습니다': 'Nobody is in yet', 'Claude Code 세션이 시작되면 여기에 나타납니다.': 'Characters appear here when a Claude Code session starts.', '훅을 아직 설치하지 않았다면': 'If the hooks are not installed yet', '미리 보려면': 'For a preview, run',
34
+ '아직 열린 층이 없습니다': 'No floors yet', '이 층에는 아무도 없습니다': 'Nobody on this floor', '아직 활동이 없습니다': 'No activity yet', '조건에 맞는 로그가 없습니다': 'No log entries match', '지금 보는 중': 'viewing', '최근 세션 자동으로 따라가기': 'Follow the latest session automatically',
35
+ '드래그로 이동 · 휠로 확대 · 캐릭터를 클릭하면 세션 선택': 'Drag to pan · wheel to zoom · click a character to select its session', '? 단축키': '? shortcuts', '단축키': 'Shortcuts', '검색 (내용·이름)': 'Search (text · name)', '선택 세션만': 'Selected only', '선택한 세션의 로그만': 'Only the selected session', '접기/펼치기': 'Collapse / expand',
36
+ '🏢 전체 보기': '🏢 Building', '🏢 사무실로': '🏢 Back to office', '📍 따라가기': '📍 Follow', '⚙️ 설정': '⚙️ Settings', '설정': 'Settings', '💬 전체': '💬 All', '💬 선택만': '💬 Selected', '🔔 꺼짐': '🔔 Off', '🔔 켜짐': '🔔 On', '🔔 차단됨': '🔔 Blocked', '🔈 꺼짐': '🔈 Off', '✨ 꺼짐': '✨ Off', '✨ 켜짐': '✨ On', '🎬 꺼짐': '🎬 Off', '🎬 켜짐': '🎬 On', '☀️ 라이트': '☀️ Light', '🌙 다크': '🌙 Dark', '🌙 라이트': '🌙 Light', '👤 고르기…': '👤 Choose…', '🎨 고르기…': '🎨 Choose…', '⌨️ 보기…': '⌨️ Show…', '🗑 치우기': '🗑 Dismiss', '🗑 저장 삭제': '🗑 Delete saved', '🎲 랜덤': '🎲 Random', '🎲 다른 별명': '🎲 Another nickname', '↺ 초기화': '↺ Reset', '저장': 'Save', '닫기': 'Close', '이름': 'Name', '끔': 'Off', '자동': 'Auto', '자동 ': 'Auto ',
37
+ '말풍선 범위': 'Bubbles', '브라우저 알림': 'Browser notifications', '대기 알림 소리': 'Waiting chime', '소리 범위': 'Sound scope', '답 기다림만': 'Waiting only', '실패·종료도': 'Also failures · end', '생동감': 'Liveliness', '테마': 'Theme', '데모': 'Demo', '캐릭터 외형': 'Character looks', '시간대 조명': 'Daylight', '층 인테리어': 'Floor interior', '언어': 'Language',
38
+ '🕒 자동 (실제 시각)': '🕒 Auto (real clock)', '🌅 아침': '🌅 Morning', '☀️ 낮': '☀️ Day', '🌇 저녁': '🌇 Evening', '🌙 밤': '🌙 Night',
39
+ '⌨️ 키보드 단축키': '⌨️ Keyboard shortcuts', '층 번호로 이동 (7 → 7F, 12F 는': 'Go to floor number (7 → 7F; for 12F press', '연타)': 'in a row)', '이전 · 다음 층': 'Previous · next floor', '선택한 세션 따라가기 (카메라)': 'Follow the selected session (camera)', '전체 보기 (건물 단면) ↔ 사무실': 'Building view ↔ office', '데모 켜기 · 끄기': 'Demo on · off', '말풍선 범위 (전체 ↔ 선택만)': 'Bubbles (all ↔ selected)', '대기 알림 소리 켜기 · 끄기': 'Waiting chime on · off', '테마 (라이트 ↔ 다크)': 'Theme (light ↔ dark)', '활동 로그 검색': 'Search the activity log', '닫기 · 검색 지우기 · 따라가기 해제': 'Close · clear search · stop following', '이 도움말': 'This help', '입력란에 포커스가 있을 때는 단축키가 동작하지 않습니다': 'Shortcuts are off while an input field has focus',
40
+ '저장은 이 브라우저에만 남습니다 (localStorage)': 'Saved in this browser only (localStorage)', '— 고른 뒤 파츠를 더 바꿀 수 있어요': '— pick one, then tweak the parts', '· 바로 저장': '· saves immediately', '프리셋 ': 'Preset ', '프리셋': 'Preset', '캐릭터 이름 ': 'Character name ', '몸': 'Body', '머리': 'Hair', '상의': 'Top', '하의': 'Bottom', '신발': 'Shoes', '안경': 'Glasses', '모자': 'Hat', '소품': 'Extras', '피부색': 'Skin', '털색': 'Fur', '금속색': 'Metal', 'Claude 기본': 'Default Claude', '서브에이전트 기본': 'Default subagent', '세션 색 자동': 'session color (auto)', '직접 고르기': 'Custom', ' · 기본': ' · default', '다른 별명 뽑기': 'Draw another nickname',
41
+ // 아바타 옵션
42
+ '사람': 'Human', '로봇': 'Robot', '고양이': 'Cat', '곰': 'Bear', '짧은': 'Short', '앞머리': 'Bangs', '단발': 'Bob', '긴 머리': 'Long', '포니테일': 'Ponytail', '양갈래': 'Twin tails', '똥머리': 'Bun', '곱슬': 'Curly', '삐죽': 'Spiky', '민머리': 'Bald', '모호크': 'Mohawk', '아프로': 'Afro',
43
+ '티셔츠': 'T-shirt', '후드': 'Hoodie', '셔츠': 'Shirt', '목폴라': 'Turtleneck', '조끼': 'Vest', '정장': 'Suit', '가운': 'Gown', '원피스': 'Dress', '바지': 'Pants', '반바지': 'Shorts', '치마': 'Skirt',
44
+ '없음': 'None', '둥근 안경': 'Round glasses', '네모 안경': 'Square glasses', '선글라스': 'Sunglasses', '고글': 'Goggles', '외알 안경': 'Monocle', '캡': 'Cap', '비니': 'Beanie', '베레모': 'Beret', '머리띠': 'Headband', '버킷햇': 'Bucket hat', '실크햇': 'Top hat', '마법사': 'Wizard', '왕관': 'Crown', '헤드폰': 'Headphones', '스카프': 'Scarf', '사원증': 'Badge', '귀걸이': 'Earring', '나비넥타이': 'Bow tie', '수염': 'Beard', '마스크': 'Mask', '망토': 'Cape',
45
+ '개발자': 'Developer', '연구원': 'Researcher', '캐주얼': 'Casual', '예술가': 'Artist', '임금님': 'King',
46
+ // 층 스타일
47
+ '따뜻한 우드': 'Warm Wood', '쿨 그레이': 'Cool Gray', '민트': 'Mint', '라벤더': 'Lavender', '샌드': 'Sand', '파스텔': 'Pastel', '(데모)': '(demo)',
48
+ // 툴팁
49
+ '지금': 'Now', '대상': 'Target', '요청': 'Prompt', '브랜치': 'Branch', '경로': 'Path', '종류': 'Type', '이어가기: ': 'Resume: ', ' — 클릭해 선택하면 우측 세션 카드에서 복사': ' — click to select, then copy from the session card',
50
+ // 배너·기타
51
+ '방금': 'just now', '시작': 'start', '초기 위치': 'Reset view', '확대': 'Zoom in', '축소': 'Zoom out',
52
+ // 긴 title 들
53
+ 'Claude 가 질문하거나 허락을 기다리면 브라우저 알림을 보냅니다': 'Browser notification when Claude asks a question or waits for permission',
54
+ "가짜 세션 4개로 사무실을 북적이게 (실제 세션과 별도 층, '(데모)' 표시)": "Fill the office with 4 fake sessions (separate floors marked '(demo)')",
55
+ '끝난 세션을 목록에서 치우기 (TTL 을 기다리지 않음)': 'Remove the finished session from the list now (without waiting for the TTL)',
56
+ '나 / Claude / 서브에이전트 캐릭터 외형 고르기 — 프리셋·몸 종류(사람·로봇·고양이·곰)·파츠, 지금 보는 세션의 캐릭터 이름': 'Customize me / Claude / subagents — presets, body (human · robot · cat · bear), parts, and the name of the session you are viewing',
57
+ '다크 모드 전환': 'Toggle dark mode', '다크 모드로': 'Switch to dark', '라이트 모드로': 'Switch to light',
58
+ '답을 기다리는 세션이 생기면 짧은 소리 (한 번 클릭해 켜야 브라우저가 소리를 허용)': 'Short chime when a session starts waiting for you (click once so the browser allows audio)',
59
+ '답을 기다릴 때만 (높은 2음)': 'Only when waiting for you (high two-tone)', '도구 실패·세션 종료에도 낮은 2음': 'Also on tool failure and session end (low two-tone)',
60
+ '말풍선을 선택한 세션만 보일지 전체를 보일지': 'Show bubbles for the selected session only, or for all', '선택한 세션의 Claude 를 카메라가 따라갑니다 (F)': 'The camera follows the selected session\'s Claude (F)',
61
+ '쉬는 캐릭터가 휴게실·카페·테라스 안을 옮겨 다니거나 산책하는 자율 행동': 'Resting characters wander around their rest room and go for walks',
62
+ '실제 시각에 따라 창문·테라스·실내 빛이 바뀝니다 (아침 06~10 · 낮 10~17 · 저녁 17~20 · 밤 20~06). 미리보기로 시간대를 고정할 수도 있어요': 'Window, terrace and room light follow the real clock (morning 06–10 · day 10–17 · evening 17–20 · night 20–06). You can also fix one period for a preview',
63
+ '이 세션 캐릭터의 이름 — 비우면 자동 별명, 이 브라우저에만 저장': 'This session\'s character name — empty means an automatic nickname; saved in this browser only',
64
+ '저장한 설정을 지우고 기본값으로': 'Delete the saved look and go back to defaults',
65
+ '지금 보는 층의 인테리어 프리셋 (층별 안내의 🎨 와 같음) — 색과 사무실 구조가 바뀝니다': 'Interior preset of the floor you are viewing (same as 🎨 in Floors) — changes colors and the office layout',
66
+ '층을 건물처럼 쌓아 한눈에 (O)': 'Stack the floors like a building (O)', '키보드 단축키 표': 'Keyboard shortcut table', '터미널에서 이 세션을 이어가는 명령을 복사': 'Copy the command that resumes this session in a terminal', '이 층 인테리어 스타일 고르기': 'Pick this floor\'s interior style',
67
+ '이 브라우저는 알림을 지원하지 않습니다': 'This browser does not support notifications', '답을 기다리고 있어요': 'waiting for your answer',
68
+ '한국어 / English': '한국어 / English', '화면 언어': 'UI language',
69
+ '드래그로 이동': 'Drag to pan', '휠로 확대': 'wheel to zoom', '캐릭터를 클릭하면 세션 선택': 'click a character to select its session',
70
+ '숫자 층 번호로 이동 (12F 는 1,2 연타)': 'Digits go to a floor number (12F: press 1, 2)', '←/→ 이전·다음 층': '←/→ previous · next floor', 'F 따라가기': 'F follow', 'A 자동 따라가기': 'A auto-follow', 'D 데모': 'D demo', 'O 전체 보기': 'O building view', 'B 말풍선': 'B bubbles', 'S 소리': 'S sound', 'T 테마': 'T theme', '/ 로그 검색': '/ log search', 'Esc 닫기': 'Esc close', '? 이 도움말': '? this help',
71
+ '어디서 무엇을 하나 ': 'Who goes where ',
72
+ };
73
+
74
+ // ---------- 패턴 규칙 (템플릿 문자열) ----------
75
+ const RULES = [
76
+ [/^"(.+)" 검색 중$/, (m) => `searching "${m[1]}"`],
77
+ [/^(.+) 읽는 중$/, (m) => `reading ${m[1]}`],
78
+ [/^(.+) 수정 중$/, (m) => `editing ${m[1]}`],
79
+ [/^(.+) 작성 중$/, (m) => `writing ${m[1]}`],
80
+ [/^(.+) 실행 중$/, (m) => `running ${m[1]}`],
81
+ [/^(.+) 찾는 중$/, (m) => `looking for ${m[1]}`],
82
+ [/^(.+) 둘러보는 중$/, (m) => `browsing ${m[1]}`],
83
+ [/^(.+) 맡기는 중$/, (m) => `delegating: ${m[1]}`],
84
+ [/^(.+) 조회 중$/, (m) => `querying ${m[1]}`],
85
+ [/^(.+) 사용 중$/, (m) => `using ${m[1]}`],
86
+ [/^(.+) 실패: (.+)$/, (m) => `${m[1]} failed: ${m[2]}`],
87
+ [/^허락 요청: (.+)$/, (m) => `permission request: ${m[1]}`],
88
+ [/^완료: (.+)$/, (m) => `done: ${m[1]}`],
89
+ [/^알림 수신 \((.+)\)$/, (m) => `notification (${m[1]})`],
90
+ [/^세션 종료 \((.+)\)$/, (m) => `session ended (${m[1]})`],
91
+ [/^세션 (startup|resume|clear|compact|\w+)$/, (m) => `session ${m[1]}`],
92
+ [/^기억 정리 \((.+)\)$/, (m) => `compacting memory (${m[1]})`],
93
+ [/^(.+) 출근$/, (m) => `${m[1]} arrived`],
94
+ [/^(.+) 퇴근(: .*)?$/, (m) => `${m[1]} left${m[2] || ''}`],
95
+ [/^(.+) 세션을 목록에서 치우기$/, (m) => `Dismiss this ${tr(m[1])} session`],
96
+ [/^(\d+)F (.+) 의 Claude$/, (m) => `Claude of ${m[1]}F ${m[2]}`],
97
+ [/^(.+) \(나\)$/, (m) => `${m[1]} (me)`],
98
+ [/^(.+) 프리셋 적용$/, (m) => `Apply the ${tr(m[1])} preset`],
99
+ [/^🎨 (\d+)F 인테리어$/, (m) => `🎨 ${m[1]}F interior`],
100
+ [/^(.+) 의 Claude$/, (m) => `Claude of ${m[1]}`],
101
+ [/^세션 (\d+)개$/, (m) => `${m[1]} session${m[1] === '1' ? '' : 's'}`],
102
+ [/^프로젝트 (\d+)개$/, (m) => `${m[1]} project${m[1] === '1' ? '' : 's'}`],
103
+ [/^(\d+)명 근무 중$/, (m) => `${m[1]} at work`],
104
+ [/^(\d+)명 대기$/, (m) => `${m[1]} waiting`],
105
+ [/^(\d+)건$/, (m) => `${m[1]} entries`],
106
+ [/^(\d+) \/ (\d+)건$/, (m) => `${m[1]} / ${m[2]} entries`],
107
+ [/^(\d+)초$/, (m) => `${m[1]}s`],
108
+ [/^(\d+)분 (\d+)초$/, (m) => `${m[1]}m ${m[2]}s`],
109
+ [/^(\d+)시간 (\d+)분$/, (m) => `${m[1]}h ${m[2]}m`],
110
+ [/^(\d+)초 전$/, (m) => `${m[1]}s ago`],
111
+ [/^(\d+)분 전$/, (m) => `${m[1]} min ago`],
112
+ [/^(\d+)시간 전$/, (m) => `${m[1]} h ago`],
113
+ [/^(\d+)일 전$/, (m) => `${m[1]} d ago`],
114
+ [/^세션 시작 (.+) · 마지막 활동 (.+) · (.+)$/, (m) => `started ${tr(m[1])} · last activity ${tr(m[2])} · ${m[3]}`],
115
+ [/^(.+) \(데모\)$/, (m) => `${m[1]} (demo)`],
116
+ [/^· (.+)$/, (m) => `· ${tr(m[1])}`],
117
+ [/^(\d+)F (.+)$/, (m) => `${m[1]}F ${tr(m[2])}`],
118
+ ];
119
+
120
+ const cache = new Map();
121
+ const HANGUL = /[가-힣]/;
122
+ const EMOJI_LEAD = /^(\p{Extended_Pictographic}\uFE0F?)\s+(.+)$/u;
123
+ // 사전 → 패턴 → 이모지 머리 → ' → ' / ' · ' 로 나눠 조각별. 모르는 조각(사용자 프롬프트·세션 제목·Claude 메시지)은 손대지 않는다
124
+ function translate(s) {
125
+ if (DICT[s] !== undefined) return DICT[s];
126
+ const t = s.trim();
127
+ if (t !== s && DICT[t] !== undefined) return s.replace(t, DICT[t]);
128
+ if (/^".*"$/s.test(t)) return s; // 로그의 "프롬프트" 는 사용자 글
129
+ for (const [re, fn] of RULES) { const m = re.exec(t); if (m) return s.replace(t, fn(m)); }
130
+ const em = EMOJI_LEAD.exec(t);
131
+ if (em) { const inner = tr(em[2]); return inner === em[2] ? s : s.replace(t, `${em[1]} ${inner}`); }
132
+ if (t.includes(' → ')) return s.replace(t, t.split(' → ').map((p) => tr(p)).join(' → '));
133
+ if (t.includes(' · ')) return s.replace(t, t.split(' · ').map((p) => tr(p)).join(' · '));
134
+ return s;
135
+ }
136
+ /** 문자열 번역. 한국어 모드이거나 한글이 없으면 그대로 */
137
+ export function tr(s) {
138
+ if (lang !== 'en' || typeof s !== 'string' || !HANGUL.test(s)) return s;
139
+ let v = cache.get(s);
140
+ if (v === undefined) { v = translate(s); if (cache.size > 5000) cache.clear(); cache.set(s, v); }
141
+ return v;
142
+ }
143
+
144
+ // ---------- DOM ----------
145
+ const originals = new WeakMap(); // Text → 원문 (앱이 쓴 값)
146
+ const written = new WeakMap(); // Text → 우리가 마지막으로 쓴 값
147
+ const attrOrig = new WeakMap(); // Element → { attr: 원문 }
148
+ const attrWritten = new WeakMap();// Element → { attr: 우리가 쓴 값 }
149
+ const ATTRS = ['title', 'placeholder', 'data-tip', 'aria-label'];
150
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'CODE', 'PRE', 'KBD']);
151
+ function applyText(node) {
152
+ const cur = node.nodeValue;
153
+ if (written.get(node) !== cur) originals.set(node, cur); // 처음 보거나 앱이 새로 썼다
154
+ const orig = originals.get(node);
155
+ const want = lang === 'en' ? tr(orig) : orig;
156
+ if (cur !== want) node.nodeValue = want;
157
+ written.set(node, want);
158
+ }
159
+ function applyAttrs(el) {
160
+ let o = attrOrig.get(el), w = attrWritten.get(el);
161
+ for (const a of ATTRS) {
162
+ if (!el.hasAttribute(a)) continue;
163
+ if (!o) { o = {}; attrOrig.set(el, o); w = {}; attrWritten.set(el, w); }
164
+ const cur = el.getAttribute(a);
165
+ if (w[a] !== cur) o[a] = cur;
166
+ const want = lang === 'en' ? tr(o[a]) : o[a];
167
+ if (cur !== want) el.setAttribute(a, want);
168
+ w[a] = want;
169
+ }
170
+ }
171
+ function walk(root) {
172
+ if (!root) return;
173
+ if (root.nodeType === 3) { if (!root.parentNode || !SKIP.has(root.parentNode.nodeName)) applyText(root); return; }
174
+ if (root.nodeType !== 1 && root.nodeType !== 11) return;
175
+ if (root.nodeType === 1) { if (SKIP.has(root.nodeName)) return; applyAttrs(root); }
176
+ for (const c of root.childNodes) walk(c);
177
+ }
178
+ let observer = null;
179
+ /** 페이지에 붙인다: 지금 DOM 을 번역하고, 이후 바뀌는 노드도 따라간다 */
180
+ export function attach(root = document.body) {
181
+ walk(root);
182
+ if (observer) return;
183
+ observer = new MutationObserver((muts) => {
184
+ for (const m of muts) {
185
+ if (m.type === 'characterData') applyText(m.target);
186
+ else if (m.type === 'attributes') applyAttrs(m.target);
187
+ else for (const n of m.addedNodes) walk(n);
188
+ }
189
+ });
190
+ observer.observe(root, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ATTRS });
191
+ }
192
+ export function setLang(v) {
193
+ if (v !== 'ko' && v !== 'en') return;
194
+ if (v === lang) return;
195
+ lang = v;
196
+ try { localStorage.setItem(KEY, v); } catch {}
197
+ cache.clear();
198
+ if (typeof document !== 'undefined') { document.documentElement.lang = v; walk(document.body); }
199
+ for (const fn of listeners) fn(v);
200
+ }
package/public/js/main.js CHANGED
@@ -14,6 +14,7 @@ import { createFloorStyleBook } from './scene/floorStyle.js';
14
14
  import { createFloorStylePicker } from './floorStylePicker.js';
15
15
  import { layoutOfPreset } from './scene/map.js';
16
16
  import { buildLayout, roomLabelIn } from './scene/layouts.js';
17
+ import { attach as attachI18n, setLang, getLang, tr } from './i18n.js';
17
18
 
18
19
  const canvas = document.getElementById('office');
19
20
  const scene = createScene(canvas, store);
@@ -248,8 +249,8 @@ store.subscribe((kind, detail) => {
248
249
  const main = session.agents && session.agents.main;
249
250
  if (!canNotify() || Notification.permission !== 'granted') return;
250
251
  try {
251
- const n = new Notification(`${floor ? floor.no + 'F ' + floor.name : '사무실'} · ${session.title || '제목 없는 세션'}`, {
252
- body: main ? `${main.icon || '🙋'} ${main.bubble}` : '답을 기다리고 있어요',
252
+ const n = new Notification(`${floor ? floor.no + 'F ' + tr(floor.name) : tr('사무실')} · ${session.title || tr('제목 없는 세션')}`, {
253
+ body: main ? `${main.icon || '🙋'} ${tr(main.bubble)}` : tr('답을 기다리고 있어요'),
253
254
  tag: `ai-office-${session.id}`,
254
255
  });
255
256
  n.onclick = () => { window.focus(); store.selectSession(session.id); n.close(); };
@@ -359,6 +360,13 @@ document.addEventListener('keydown', (e) => {
359
360
  e.preventDefault();
360
361
  });
361
362
  document.getElementById('stage-hint').textContent += ' · ? 단축키';
363
+
364
+ // ---------- 언어 (FE-45): DOM 은 i18n 이 자동 번역, 캔버스 글자는 tr() 로 그린다 ----------
365
+ const langSel = document.getElementById('lang-select');
366
+ langSel.value = getLang();
367
+ document.documentElement.lang = getLang();
368
+ langSel.onchange = () => { setLang(langSel.value); dirty = true; };
369
+ attachI18n(document.body);
362
370
  document.getElementById('stage-hint').title = KEYS_HELP;
363
371
 
364
372
  // ---------- 세션 이어가기 명령 복사 ----------
@@ -3,6 +3,7 @@ import { GRID, project, worldBounds, layoutOfPreset } from './scene/map.js';
3
3
  import { buildLayout } from './scene/layouts.js';
4
4
  import { drawFloor, drawEntities, roundRect, withFloorStyle } from './scene/render.js';
5
5
  import { palette, DEFAULT_PRESET } from './scene/floorStyle.js';
6
+ import { tr } from './i18n.js';
6
7
 
7
8
  const MINI_SCALE = 0.5; // 미니 사무실 캐시 해상도 (월드 1px → 0.5px)
8
9
  const STRIDE = 0.8; // 층 간격 (이미지 높이 대비) — 살짝 겹쳐 건물처럼
@@ -122,11 +123,12 @@ export function createOverview(canvas, store, { onPick, styleOf } = {}) {
122
123
  const sessions = store.sessionsOfFloor(f.id);
123
124
  const status = store.floorStatus(f.id);
124
125
  const waiting = store.waitingOfFloor(f.id).length;
125
- const name = f.name.length > 22 ? f.name.slice(0, 21) + '…' : f.name;
126
+ const fname = tr(f.name);
127
+ const name = fname.length > 22 ? fname.slice(0, 21) + '…' : fname;
126
128
  ctx.font = '700 12px D2Coding, NanumSquare, system-ui, sans-serif';
127
129
  const nameW = ctx.measureText(name).width;
128
130
  ctx.font = '11px D2Coding, NanumSquare, system-ui, sans-serif';
129
- const meta = `세션 ${sessions.length}개 · ${STATUS_LABEL[status]}${waiting ? ` · ${waiting}명 대기` : ''}`;
131
+ const meta = tr(`세션 ${sessions.length}개 · ${STATUS_LABEL[status]}${waiting ? ` · ${waiting}명 대기` : ''}`);
130
132
  const metaW = ctx.measureText(meta).width;
131
133
  const bw = Math.max(nameW, metaW) + 62, bh = 40;
132
134
  const lx = ox + 8, ly = oy + 6;
@@ -6,6 +6,7 @@ import { findPath, nearestWalkable } from './pathfind.js';
6
6
  import { shadow, shade, FONT } from './sprites.js';
7
7
  import { roundRect, makeEntity } from './render.js';
8
8
  import { elapsed } from '../store.js';
9
+ import { tr } from '../i18n.js';
9
10
  import { drawAvatar } from './avatar.js';
10
11
 
11
12
  const SPEED = 3.6; // 타일/초
@@ -151,7 +152,7 @@ function drawOverlay(ctx, camera, a, now, bubbleMode) {
151
152
  ctx.textBaseline = 'middle';
152
153
  // 이름표 (발 아래)
153
154
  ctx.font = `700 9.5px ${FONT}`;
154
- const name = a.user ? `${a.name} (나)` : a.name;
155
+ const name = a.user ? tr(`${a.name} (나)`) : a.name;
155
156
  const nw = ctx.measureText(name).width + 18;
156
157
  const ny = s.y + 10;
157
158
  ctx.fillStyle = 'rgba(255,255,255,0.95)';
@@ -167,9 +168,9 @@ function drawOverlay(ctx, camera, a, now, bubbleMode) {
167
168
  const amb = (a.ambient && a.ambient.phase !== 'back' ? a.ambient.spot : null) || roam;
168
169
  if ((amb || a.bubble) && !a.user && (bubbleMode !== 'selected' || a.selected)) {
169
170
  ctx.font = `8px ${FONT}`;
170
- let text = amb ? `${amb.icon} ${amb.bubble}` : `${a.icon ? a.icon + ' ' : ''}${a.bubble}`;
171
+ let text = amb ? `${amb.icon} ${tr(amb.bubble)}` : `${a.icon ? a.icon + ' ' : ''}${tr(a.bubble)}`;
171
172
  if (text.length > 30) text = text.slice(0, 29) + '…';
172
- const ago = elapsed(a.since, Date.now()); // now 는 rAF 시각(페이지 로드 기준)이라 epoch 인 since 와 빼면 안 된다
173
+ const ago = tr(elapsed(a.since, Date.now())); // now 는 rAF 시각(페이지 로드 기준)이라 epoch 인 since 와 빼면 안 된다
173
174
  const full = `${text} · ${ago}`;
174
175
  const bw = Math.min(240, ctx.measureText(full).width + 16), bh = 17, r = 8.5;
175
176
  const headTop = s.y - 62 * z;
@@ -5,6 +5,7 @@
5
5
  // 이 관계로 위상 정렬한다. 정적 엔티티끼리의 관계는 한 번만 계산해 두고, 캐릭터와의 관계만 매 프레임 계산한다.
6
6
  import { GRID, ROOMS, CORRIDOR, FURNITURE, WALLS, DOORS, LABEL, project, roomAt, worldBounds, setTheme as setMapTheme, setFloorStyle as setMapFloorStyle } from './map.js';
7
7
  import * as MAP from './map.js';
8
+ import { tr } from '../i18n.js';
8
9
  import { tile, drawFurniture, wallPieces, drawWallPiece, drawDoorMat, drawDoorPost } from './sprites.js';
9
10
  import { resolve as resolveDaylight, setPeriod as setDaylightPeriod, palette as daylight } from './daylight.js';
10
11
 
@@ -383,7 +384,7 @@ export function drawLabels(ctx, camera) {
383
384
  for (const r of ROOMS) {
384
385
  const p = project(r.x0 + 0.5, r.y0 + 0.5, 0);
385
386
  const s = camera.worldToScreen(p.x, p.y);
386
- const text = r.label;
387
+ const text = tr(r.label);
387
388
  const w = ctx.measureText(text).width + 16;
388
389
  const x = s.x, y = s.y - 6;
389
390
  const dark = MAP.THEME === 'dark';
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Tauri 사이드카용 Node 바이너리를 내려받는다 (산출물은 커밋하지 않음). 맥·리눅스·Windows 공통.
4
+ // node scripts/fetch-node.js # 현재 머신
5
+ // NODE_VERSION=22.12.0 node scripts/fetch-node.js
6
+ // node scripts/fetch-node.js --target x86_64-pc-windows-msvc # 교차 (CI)
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+ const { execFileSync } = require('child_process');
11
+
12
+ const VER = process.env.NODE_VERSION || '22.12.0';
13
+ const TARGETS = { // rust triple → nodejs.org 배포 이름
14
+ 'aarch64-apple-darwin': { plat: 'darwin-arm64', ext: 'tar.gz', bin: 'bin/node', out: 'node' },
15
+ 'x86_64-apple-darwin': { plat: 'darwin-x64', ext: 'tar.gz', bin: 'bin/node', out: 'node' },
16
+ 'x86_64-unknown-linux-gnu': { plat: 'linux-x64', ext: 'tar.gz', bin: 'bin/node', out: 'node' },
17
+ 'aarch64-unknown-linux-gnu': { plat: 'linux-arm64', ext: 'tar.gz', bin: 'bin/node', out: 'node' },
18
+ 'x86_64-pc-windows-msvc': { plat: 'win-x64', ext: 'zip', bin: 'node.exe', out: 'node.exe' },
19
+ 'aarch64-pc-windows-msvc': { plat: 'win-arm64', ext: 'zip', bin: 'node.exe', out: 'node.exe' },
20
+ };
21
+ function hostTriple() {
22
+ const arch = { arm64: 'aarch64', x64: 'x86_64' }[process.arch];
23
+ const osn = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' }[process.platform];
24
+ return `${arch}-${osn}`;
25
+ }
26
+ const i = process.argv.indexOf('--target');
27
+ const triple = i >= 0 ? process.argv[i + 1] : hostTriple();
28
+ const t = TARGETS[triple];
29
+ if (!t) { console.error(`지원하지 않는 대상: ${triple}`); process.exit(1); }
30
+
31
+ const OUT = path.join(__dirname, '..', 'app', 'src-tauri', 'binaries');
32
+ const dest = path.join(OUT, triple.endsWith('windows-msvc') ? `node-${triple}.exe` : `node-${triple}`);
33
+ fs.mkdirSync(OUT, { recursive: true });
34
+
35
+ (async () => {
36
+ if (!fs.existsSync(dest)) {
37
+ const name = `node-v${VER}-${t.plat}`;
38
+ const url = `https://nodejs.org/dist/v${VER}/${name}.${t.ext}`;
39
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-office-node-'));
40
+ const archive = path.join(tmp, `${name}.${t.ext}`);
41
+ console.log(`downloading node v${VER} (${t.plat})…`);
42
+ const res = await fetch(url);
43
+ if (!res.ok) { console.error(`다운로드 실패 ${res.status} ${url}`); process.exit(1); }
44
+ fs.writeFileSync(archive, Buffer.from(await res.arrayBuffer()));
45
+ // tar 는 맥·리눅스·Windows 10+ 모두 있고 zip 도 풀 수 있다
46
+ execFileSync('tar', ['-xf', archive, '-C', tmp], { stdio: 'inherit' });
47
+ fs.copyFileSync(path.join(tmp, name, t.bin), dest);
48
+ if (process.platform !== 'win32') fs.chmodSync(dest, 0o755);
49
+ fs.rmSync(tmp, { recursive: true, force: true });
50
+ }
51
+ const st = fs.statSync(dest);
52
+ console.log(`${dest} ${st.size} bytes`);
53
+ if (triple === hostTriple()) { try { console.log(execFileSync(dest, ['--version']).toString().trim()); } catch {} }
54
+ })().catch((e) => { console.error(e.message); process.exit(1); });
@@ -1,24 +1,3 @@
1
1
  #!/bin/sh
2
- # Tauri 사이드카용 Node 바이너리를 내려받는다 (산출물은 커밋하지 않음).
3
- # sh scripts/fetch-node.sh # 현재 머신 아키텍처
4
- # NODE_VERSION=22.12.0 sh scripts/fetch-node.sh
5
- set -e
6
- VER="${NODE_VERSION:-22.12.0}"
7
- case "$(uname -s)-$(uname -m)" in
8
- Darwin-arm64) PLAT=darwin-arm64; TRIPLE=aarch64-apple-darwin ;;
9
- Darwin-x86_64) PLAT=darwin-x64; TRIPLE=x86_64-apple-darwin ;;
10
- Linux-x86_64) PLAT=linux-x64; TRIPLE=x86_64-unknown-linux-gnu ;;
11
- Linux-aarch64) PLAT=linux-arm64; TRIPLE=aarch64-unknown-linux-gnu ;;
12
- *) echo "지원하지 않는 플랫폼: $(uname -s)-$(uname -m)"; exit 1 ;;
13
- esac
14
- OUT="$(cd "$(dirname "$0")/.." && pwd)/app/src-tauri/binaries"
15
- mkdir -p "$OUT"
16
- TMP="${TMPDIR:-/tmp}/node-$VER-$PLAT"
17
- if [ ! -x "$OUT/node-$TRIPLE" ]; then
18
- echo "downloading node v$VER ($PLAT)…"
19
- curl -sSL -o "$TMP.tar.gz" "https://nodejs.org/dist/v$VER/node-v$VER-$PLAT.tar.gz"
20
- mkdir -p "$TMP" && tar -xzf "$TMP.tar.gz" -C "$TMP" --strip-components=1
21
- cp "$TMP/bin/node" "$OUT/node-$TRIPLE" && chmod +x "$OUT/node-$TRIPLE"
22
- rm -rf "$TMP" "$TMP.tar.gz"
23
- fi
24
- "$OUT/node-$TRIPLE" --version && ls -la "$OUT/node-$TRIPLE" | awk '{print $5" bytes"}'
2
+ # 호환용: Node 스크립트로 위임 (맥·리눅스·Windows 공통 구현은 fetch-node.js)
3
+ exec node "$(dirname "$0")/fetch-node.js" "$@"
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // 메뉴바(트레이)용 단색 템플릿 아이콘 생성: 검정 픽셀 + 알파만 있으면 macOS 가 테마에 맞춰 칠한다.
4
+ // 22pt 기준 설계, @1x(22px)·@2x(44px) 두 장을 app/src-tauri/icons/ 에 쓴다. 의존성 없음(PNG 직접 인코딩).
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const zlib = require('zlib');
8
+
9
+ // 22×22 캔버스. 건물 본체 + 창문 2×3 + 문 + 옥상 안테나. ('#' 불투명, '.' 투명)
10
+ const ART = [
11
+ '......................',
12
+ '..........#...........',
13
+ '..........#...........',
14
+ '....##############....',
15
+ '....##############....',
16
+ '....##..##..##..##....',
17
+ '....##..##..##..##....',
18
+ '....##############....',
19
+ '....##############....',
20
+ '....##..##..##..##....',
21
+ '....##..##..##..##....',
22
+ '....##############....',
23
+ '....##############....',
24
+ '....##..##..##..##....',
25
+ '....##..##..##..##....',
26
+ '....##############....',
27
+ '....##############....',
28
+ '....######....######..'.slice(0, 22),
29
+ '....######....######..'.slice(0, 22),
30
+ '....######....######..'.slice(0, 22),
31
+ '..####################',
32
+ '......................',
33
+ ];
34
+ // 문 폭을 가운데 4px 로 맞추고 바닥선을 본체에 맞춘다
35
+ for (const y of [17, 18, 19]) ART[y] = '....#####....#####....';
36
+ ART[20] = '...################...';
37
+
38
+ function crc32(buf) { let c, crc = 0xffffffff; for (let n = 0; n < buf.length; n++) { c = (crc ^ buf[n]) & 0xff; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crc = (crc >>> 8) ^ c; } return (crc ^ 0xffffffff) >>> 0; }
39
+ function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td)); return Buffer.concat([len, td, crc]); }
40
+ function png(size, scale) {
41
+ const raw = Buffer.alloc((size * 4 + 1) * size);
42
+ for (let y = 0; y < size; y++) {
43
+ raw[y * (size * 4 + 1)] = 0; // filter none
44
+ for (let x = 0; x < size; x++) {
45
+ const on = ART[Math.floor(y / scale)][Math.floor(x / scale)] === '#';
46
+ const o = y * (size * 4 + 1) + 1 + x * 4;
47
+ raw[o] = 0; raw[o + 1] = 0; raw[o + 2] = 0; raw[o + 3] = on ? 255 : 0;
48
+ }
49
+ }
50
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
51
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
52
+ }
53
+ const out = path.join(__dirname, '..', 'app', 'src-tauri', 'icons');
54
+ fs.writeFileSync(path.join(out, 'tray.png'), png(22, 1));
55
+ fs.writeFileSync(path.join(out, 'tray@2x.png'), png(44, 2));
56
+ console.log('wrote', path.join(out, 'tray.png'), 'and tray@2x.png');
57
+ console.log(ART.join('\n'));
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // 릴리스 뒤 Homebrew tap(cozyuj/homebrew-tap)의 cask·formula 버전과 sha256 을 갱신한다.
4
+ // node scripts/update-homebrew.js [--tap ../homebrew-tap] [--version 0.25.0]
5
+ // cask: GitHub Release 의 AI.Office_<v>_aarch64.dmg, formula: npm 의 claude-ai-office-<v>.tgz (npm 에 그 버전이 있을 때만)
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+
10
+ const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : def; };
11
+ const ROOT = path.join(__dirname, '..');
12
+ const TAP = path.resolve(arg('tap', path.join(ROOT, '..', 'homebrew-tap')));
13
+ const VERSION = arg('version', require(path.join(ROOT, 'package.json')).version);
14
+
15
+ async function sha256Of(url) {
16
+ const res = await fetch(url, { redirect: 'follow' });
17
+ if (!res.ok) throw new Error(`${res.status} ${url}`);
18
+ return crypto.createHash('sha256').update(Buffer.from(await res.arrayBuffer())).digest('hex');
19
+ }
20
+ function rewrite(file, version, sha, urlVersionRe) {
21
+ let s = fs.readFileSync(file, 'utf8');
22
+ s = s.replace(/version "[^"]*"/, `version "${version}"`).replace(/sha256 "[0-9a-f]{64}"/, `sha256 "${sha}"`);
23
+ if (urlVersionRe) s = s.replace(urlVersionRe, (m) => m.replace(/\d+\.\d+\.\d+/, version));
24
+ fs.writeFileSync(file, s);
25
+ }
26
+
27
+ (async () => {
28
+ const dmg = `https://github.com/cozyuj/AI-office/releases/download/v${VERSION}/AI.Office_${VERSION}_aarch64.dmg`;
29
+ const caskSha = await sha256Of(dmg);
30
+ rewrite(path.join(TAP, 'Casks', 'ai-office.rb'), VERSION, caskSha);
31
+ console.log(`cask ${VERSION} ${caskSha}`);
32
+
33
+ const meta = await (await fetch('https://registry.npmjs.org/claude-ai-office')).json();
34
+ const npmV = meta.versions && meta.versions[VERSION] ? VERSION : meta['dist-tags'].latest;
35
+ const tgz = meta.versions[npmV].dist.tarball;
36
+ const formulaSha = await sha256Of(tgz);
37
+ rewrite(path.join(TAP, 'Formula', 'claude-ai-office.rb'), npmV, formulaSha, /claude-ai-office-\d+\.\d+\.\d+\.tgz/);
38
+ console.log(`formula ${npmV} ${formulaSha}${npmV !== VERSION ? ` (npm 에 ${VERSION} 이 아직 없어 ${npmV} 사용)` : ''}`);
39
+ })().catch((e) => { console.error(e.message); process.exit(1); });