eye-care 1.0.0

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.
Files changed (63) hide show
  1. package/AGENTS.md +48 -0
  2. package/CODE_OF_CONDUCT.md +44 -0
  3. package/CONTRIBUTING.md +67 -0
  4. package/LICENSE +21 -0
  5. package/README.md +178 -0
  6. package/bin/eye-care.js +22 -0
  7. package/build/icon.ico +0 -0
  8. package/build/icon.png +0 -0
  9. package/build/tray.png +0 -0
  10. package/build/tray@2x.png +0 -0
  11. package/out/data/exercises.js +120 -0
  12. package/out/data/exercises.js.map +1 -0
  13. package/out/data/i18n.js +211 -0
  14. package/out/data/i18n.js.map +1 -0
  15. package/out/main/backgrounds.js +180 -0
  16. package/out/main/backgrounds.js.map +1 -0
  17. package/out/main/index.js +258 -0
  18. package/out/main/index.js.map +1 -0
  19. package/out/main/preferences.js +71 -0
  20. package/out/main/preferences.js.map +1 -0
  21. package/out/main/scheduler.js +106 -0
  22. package/out/main/scheduler.js.map +1 -0
  23. package/out/preload/break.js +9 -0
  24. package/out/preload/break.js.map +1 -0
  25. package/out/preload/settings.js +18 -0
  26. package/out/preload/settings.js.map +1 -0
  27. package/out/renderer/backgrounds/forest.svg +13 -0
  28. package/out/renderer/backgrounds/mountains.svg +13 -0
  29. package/out/renderer/backgrounds/sea.svg +13 -0
  30. package/out/renderer/backgrounds/sunny-sky.svg +16 -0
  31. package/out/renderer/backgrounds/sunset.svg +18 -0
  32. package/out/renderer/break.css +265 -0
  33. package/out/renderer/break.html +38 -0
  34. package/out/renderer/break.js +452 -0
  35. package/out/renderer/settings.css +198 -0
  36. package/out/renderer/settings.html +110 -0
  37. package/out/renderer/settings.js +407 -0
  38. package/out/shared/types.js +23 -0
  39. package/out/shared/types.js.map +1 -0
  40. package/package.json +100 -0
  41. package/scripts/copy-static.js +27 -0
  42. package/scripts/make-icon.js +120 -0
  43. package/src/data/exercises.ts +120 -0
  44. package/src/data/i18n.ts +297 -0
  45. package/src/main/backgrounds.ts +144 -0
  46. package/src/main/index.ts +233 -0
  47. package/src/main/preferences.ts +34 -0
  48. package/src/main/scheduler.ts +105 -0
  49. package/src/preload/break.ts +9 -0
  50. package/src/preload/settings.ts +18 -0
  51. package/src/renderer/backgrounds/forest.svg +13 -0
  52. package/src/renderer/backgrounds/mountains.svg +13 -0
  53. package/src/renderer/backgrounds/sea.svg +13 -0
  54. package/src/renderer/backgrounds/sunny-sky.svg +16 -0
  55. package/src/renderer/backgrounds/sunset.svg +18 -0
  56. package/src/renderer/break.css +265 -0
  57. package/src/renderer/break.html +38 -0
  58. package/src/renderer/break.js +452 -0
  59. package/src/renderer/settings.css +198 -0
  60. package/src/renderer/settings.html +110 -0
  61. package/src/renderer/settings.js +407 -0
  62. package/src/shared/types.ts +82 -0
  63. package/tsconfig.json +19 -0
@@ -0,0 +1,120 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const zlib = require("zlib");
4
+
5
+ function makePng(W, H, pixelFn) {
6
+ const pixels = Buffer.alloc(W * H * 4);
7
+ pixelFn(W, H, pixels);
8
+ const raw = Buffer.alloc(H * (1 + W * 4));
9
+ for (let y = 0; y < H; y++) {
10
+ raw[y * (1 + W * 4)] = 0;
11
+ pixels.copy(raw, y * (1 + W * 4) + 1, y * W * 4, (y + 1) * W * 4);
12
+ }
13
+ const compressed = zlib.deflateSync(raw);
14
+ const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
15
+ const ihdr = Buffer.alloc(13);
16
+ ihdr.writeUInt32BE(W, 0);
17
+ ihdr.writeUInt32BE(H, 4);
18
+ ihdr[8] = 8;
19
+ ihdr[9] = 6;
20
+ const png = Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", compressed), chunk("IEND", Buffer.alloc(0))]);
21
+ return png;
22
+ }
23
+
24
+ function chunk(type, data) {
25
+ const len = Buffer.alloc(4);
26
+ len.writeUInt32BE(data.length, 0);
27
+ const typeBuf = Buffer.from(type, "ascii");
28
+ const crc = Buffer.alloc(4);
29
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
30
+ return Buffer.concat([len, typeBuf, data, crc]);
31
+ }
32
+
33
+ function crc32(buf) {
34
+ let c = 0xffffffff;
35
+ for (let i = 0; i < buf.length; i++) {
36
+ c ^= buf[i];
37
+ for (let k = 0; k < 8; k++) {
38
+ c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
39
+ }
40
+ }
41
+ return (c ^ 0xffffffff) >>> 0;
42
+ }
43
+
44
+ function inCircle(cx, cy, r, x, y) {
45
+ const dx = x - cx;
46
+ const dy = y - cy;
47
+ return dx * dx + dy * dy <= r * r;
48
+ }
49
+
50
+ function drawEye(W, H, pixels) {
51
+ const cx = W / 2;
52
+ const cy = H / 2;
53
+ const eyeW = W * 0.42;
54
+ const eyeH = H * 0.28;
55
+ for (let y = 0; y < H; y++) {
56
+ for (let x = 0; x < W; x++) {
57
+ let r = 0, g = 0, b = 0, a = 0;
58
+ const dx = (x - cx) / eyeW;
59
+ const dy = (y - cy) / eyeH;
60
+ const eyeShape = dx * dx + dy * dy <= 1;
61
+ if (eyeShape) {
62
+ r = 79; g = 140; b = 255; a = 255;
63
+ }
64
+ const pupilR = Math.min(W, H) * 0.14;
65
+ if (inCircle(cx, cy, pupilR, x, y)) {
66
+ r = 20; g = 30; b = 60; a = 255;
67
+ }
68
+ const glintR = Math.min(W, H) * 0.06;
69
+ if (inCircle(cx - pupilR * 0.3, cy - pupilR * 0.3, glintR, x, y)) {
70
+ r = 240; g = 245; b = 255; a = 255;
71
+ }
72
+ const i = (y * W + x) * 4;
73
+ pixels[i] = r;
74
+ pixels[i + 1] = g;
75
+ pixels[i + 2] = b;
76
+ pixels[i + 3] = a;
77
+ }
78
+ }
79
+ }
80
+
81
+ function makeIco(sizes) {
82
+ const pngs = sizes.map((s) => ({ size: s, png: makePng(s, s, drawEye) }));
83
+ const headerSize = 6 + pngs.length * 16;
84
+ const header = Buffer.alloc(6);
85
+ header.writeUInt16LE(0, 0);
86
+ header.writeUInt16LE(1, 2);
87
+ header.writeUInt16LE(pngs.length, 4);
88
+ const entries = [];
89
+ let offset = headerSize;
90
+ for (const { size, png } of pngs) {
91
+ const entry = Buffer.alloc(16);
92
+ entry[0] = size >= 256 ? 0 : size;
93
+ entry[1] = size >= 256 ? 0 : size;
94
+ entry[2] = 0;
95
+ entry[3] = 0;
96
+ entry.writeUInt16LE(1, 4);
97
+ entry.writeUInt16LE(32, 6);
98
+ entry.writeUInt32LE(png.length, 8);
99
+ entry.writeUInt32LE(offset, 12);
100
+ entries.push(entry);
101
+ offset += png.length;
102
+ }
103
+ return Buffer.concat([header, ...entries, ...pngs.map((p) => p.png)]);
104
+ }
105
+
106
+ const dir = path.join(__dirname, "..", "build");
107
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
108
+
109
+ const trayPng = makePng(16, 16, drawEye);
110
+ fs.writeFileSync(path.join(dir, "tray.png"), trayPng);
111
+ fs.writeFileSync(path.join(dir, "tray@2x.png"), makePng(32, 32, drawEye));
112
+ console.log("Wrote build/tray.png, build/tray@2x.png");
113
+
114
+ const icon256 = makePng(256, 256, drawEye);
115
+ fs.writeFileSync(path.join(dir, "icon.png"), icon256);
116
+ console.log("Wrote build/icon.png (256x256)");
117
+
118
+ const ico = makeIco([16, 32, 48, 64, 128, 256]);
119
+ fs.writeFileSync(path.join(dir, "icon.ico"), ico);
120
+ console.log("Wrote build/icon.ico (" + ico.length + " bytes, multi-size)");
@@ -0,0 +1,120 @@
1
+ import { Exercise } from "../shared/types";
2
+
3
+ export const EXERCISES: Exercise[] = [
4
+ {
5
+ id: "20-20-20",
6
+ name: "20-20-20 Rule",
7
+ shortDescription: "Look at something 20 feet away for 20 seconds.",
8
+ steps: [
9
+ {
10
+ text: "Look at an object at least 20 feet (6 m) away — out a window or across the room.",
11
+ durationSeconds: 20,
12
+ },
13
+ ],
14
+ source: "American Academy of Ophthalmology",
15
+ sourceUrl: "https://www.aao.org/eye-health/tips-prevention/computer-vision-syndrome",
16
+ forBreakTypes: ["mini", "long"],
17
+ },
18
+ {
19
+ id: "conscious-blink",
20
+ name: "Conscious Blinking",
21
+ shortDescription: "Blink slowly to remoisten your eyes.",
22
+ steps: [
23
+ { text: "Close your eyes gently and softly for 2 seconds.", durationSeconds: 2 },
24
+ { text: "Open slowly. Repeat the slow blink at a relaxed pace.", durationSeconds: 18 },
25
+ ],
26
+ source: "American Academy of Ophthalmology — Computer Vision Syndrome",
27
+ sourceUrl: "https://www.aao.org/eye-health/tips-prevention/computer-vision-syndrome",
28
+ forBreakTypes: ["mini", "long"],
29
+ },
30
+ {
31
+ id: "near-far-focus",
32
+ name: "Near-Far Focus Shift",
33
+ shortDescription: "Alternate focus between a near and distant target.",
34
+ steps: [
35
+ { text: "Hold your finger 10-15 inches (25-38 cm) from your eyes and focus on it.", durationSeconds: 5 },
36
+ { text: "Shift focus to an object 20 feet (6 m) or farther away.", durationSeconds: 5 },
37
+ { text: "Back to your finger.", durationSeconds: 5 },
38
+ { text: "Back to the distant object. Repeat smoothly.", durationSeconds: 5 },
39
+ ],
40
+ source: "American Optometric Association — Computer Vision Initiative",
41
+ sourceUrl: "https://www.aoa.org/healthy-eyes/caring-for-your-eyes/protecting-your-eyes/computer-vision-syndrome",
42
+ forBreakTypes: ["long"],
43
+ },
44
+ {
45
+ id: "figure-eight",
46
+ name: "Figure-8 Tracing",
47
+ shortDescription: "Trace an imaginary figure-8 with your eyes to relax eye muscles.",
48
+ steps: [
49
+ { text: "Imagine a large figure-8 on the wall about 10 feet (3 m) away.", durationSeconds: 5 },
50
+ { text: "Slowly trace it one way with your eyes only (head still).", durationSeconds: 10 },
51
+ { text: "Reverse direction and trace it back.", durationSeconds: 10 },
52
+ ],
53
+ source: "American Academy of Ophthalmology — Eye Exercises",
54
+ sourceUrl: "https://www.aao.org/eye-health/tips-prevention/eye-exercises",
55
+ forBreakTypes: ["long"],
56
+ },
57
+ {
58
+ id: "palming",
59
+ name: "Palming",
60
+ shortDescription: "Cover closed eyes with warm palms to relax.",
61
+ steps: [
62
+ { text: "Rub your palms together until they feel warm.", durationSeconds: 5 },
63
+ { text: "Close your eyes and cup your palms over them without pressing on the eyeballs.", durationSeconds: 30 },
64
+ { text: "Breathe slowly and relax. Keep the position.", durationSeconds: 25 },
65
+ ],
66
+ source: "American Academy of Ophthalmology — Eye Exercises",
67
+ sourceUrl: "https://www.aao.org/eye-health/tips-prevention/eye-exercises",
68
+ forBreakTypes: ["long"],
69
+ },
70
+ {
71
+ id: "horizontal-rolls",
72
+ name: "Horizontal Eye Rolls",
73
+ shortDescription: "Slow horizontal eye movement to relieve fatigue.",
74
+ steps: [
75
+ { text: "Look as far right as comfortable (head still).", durationSeconds: 4 },
76
+ { text: "Slowly move your gaze to the far left.", durationSeconds: 4 },
77
+ { text: "Back to center.", durationSeconds: 2 },
78
+ { text: "Repeat the slow sweep a few more times.", durationSeconds: 10 },
79
+ ],
80
+ source: "American Academy of Ophthalmology — Eye Exercises",
81
+ sourceUrl: "https://www.aao.org/eye-health/tips-prevention/eye-exercises",
82
+ forBreakTypes: ["mini", "long"],
83
+ },
84
+ ];
85
+
86
+ export function exercisesForBreak(type: "mini" | "long", durationSeconds: number): Exercise[] {
87
+ const pool = EXERCISES.filter((e) => e.forBreakTypes.includes(type));
88
+ if (pool.length === 0) return [];
89
+
90
+ const selected: Exercise[] = [];
91
+ let used = 0;
92
+ let i = 0;
93
+
94
+ while (used < durationSeconds) {
95
+ const ex = pool[i % pool.length];
96
+ const exTotal = ex.steps.reduce((s, st) => s + st.durationSeconds, 0);
97
+ const remaining = durationSeconds - used;
98
+
99
+ if (exTotal <= remaining) {
100
+ selected.push({ ...ex, id: ex.id + "-" + i });
101
+ used += exTotal;
102
+ } else {
103
+ const scaled: Exercise = {
104
+ ...ex,
105
+ id: ex.id + "-" + i,
106
+ steps: ex.steps.map((st) => ({
107
+ text: st.text,
108
+ durationSeconds: Math.max(3, Math.round((st.durationSeconds / exTotal) * remaining)),
109
+ })),
110
+ };
111
+ const scaledTotal = scaled.steps.reduce((s, st) => s + st.durationSeconds, 0);
112
+ selected.push(scaled);
113
+ used += scaledTotal;
114
+ break;
115
+ }
116
+ i += 1;
117
+ }
118
+
119
+ return selected;
120
+ }
@@ -0,0 +1,297 @@
1
+ import { Language } from "../shared/types";
2
+
3
+ export type ExerciseTranslations = {
4
+ name: string;
5
+ shortDescription: string;
6
+ steps: string[];
7
+ };
8
+
9
+ export type TranslationKey =
10
+ | "appTitle"
11
+ | "settingsTitle"
12
+ | "settingsHint"
13
+ | "miniBreakSection"
14
+ | "longBreakSection"
15
+ | "generalSection"
16
+ | "enableMiniBreaks"
17
+ | "enableLongBreaks"
18
+ | "intervalMinutes"
19
+ | "durationSeconds"
20
+ | "soundNotifications"
21
+ | "strictMode"
22
+ | "strictModeHint"
23
+ | "languageLabel"
24
+ | "saved"
25
+ | "medicalDisclaimer"
26
+ | "breakTitleMini"
27
+ | "breakSubtitleMini"
28
+ | "breakTitleLong"
29
+ | "breakSubtitleLong"
30
+ | "breakComplete"
31
+ | "breakCompleteSubtitle"
32
+ | "skipBreak"
33
+ | "sourcePrefix"
34
+ | "restEyes"
35
+ | "restEyesInstruction"
36
+ | "trayNextBreak"
37
+ | "trayPaused"
38
+ | "trayTakeMiniNow"
39
+ | "trayTakeLongNow"
40
+ | "trayPause1Hour"
41
+ | "trayResume"
42
+ | "traySettings"
43
+ | "trayAbout"
44
+ | "trayQuit"
45
+ | "noBreakScheduled"
46
+ | "exercise20_20_20"
47
+ | "exerciseConsciousBlink"
48
+ | "exerciseNearFarFocus"
49
+ | "exerciseFigure8"
50
+ | "exercisePalming"
51
+ | "exerciseHorizontalRolls"
52
+ | "guideLook20ft"
53
+ | "guideYou"
54
+ | "guideFarL"
55
+ | "guideFarR"
56
+ | "guideSlowBlink"
57
+ | "guideNear"
58
+ | "guideFar"
59
+ | "guideTrace8"
60
+ | "guideWarmPalms"
61
+ | "guideBreathe"
62
+ | "guideSweepLR"
63
+ | "guideL"
64
+ | "guideR"
65
+ | "guideRestEyes"
66
+ | "step20_20_20"
67
+ | "stepBlinkClose"
68
+ | "stepBlinkOpen"
69
+ | "stepNearFocus"
70
+ | "stepFarFocus"
71
+ | "stepNearBack"
72
+ | "stepFarBack"
73
+ | "stepFigure8Imagine"
74
+ | "stepFigure8Trace"
75
+ | "stepFigure8Reverse"
76
+ | "stepPalmRub"
77
+ | "stepPalmCover"
78
+ | "stepPalmBreathe"
79
+ | "stepHoriRight"
80
+ | "stepHoriLeft"
81
+ | "stepHoriCenter"
82
+ | "stepHoriRepeat";
83
+
84
+ type Dict = Record<TranslationKey, string>;
85
+
86
+ const en: Dict = {
87
+ appTitle: "eye-care",
88
+ settingsTitle: "eye-care Settings",
89
+ settingsHint: "Adjust break intervals and durations. Changes are saved automatically.",
90
+ miniBreakSection: "Mini break",
91
+ longBreakSection: "Long break",
92
+ generalSection: "General",
93
+ enableMiniBreaks: "Enable mini breaks",
94
+ enableLongBreaks: "Enable long breaks",
95
+ intervalMinutes: "Interval (minutes)",
96
+ durationSeconds: "Duration (seconds)",
97
+ soundNotifications: "Sound notifications",
98
+ strictMode: "Strict mode (no skip)",
99
+ strictModeHint: "",
100
+ languageLabel: "Language",
101
+ saved: "Saved",
102
+ medicalDisclaimer:
103
+ "eye-care is for eye-fatigue relief only. It is not a medical device and does not diagnose, treat, or cure any condition. If you experience persistent eye discomfort, consult a licensed ophthalmologist.",
104
+ breakTitleMini: "Mini eye break",
105
+ breakSubtitleMini: "A quick reset for your eyes.",
106
+ breakTitleLong: "Long eye break",
107
+ breakSubtitleLong: "Follow the guided exercises.",
108
+ breakComplete: "Break complete",
109
+ breakCompleteSubtitle: "Great job. Back to work!",
110
+ skipBreak: "Skip break",
111
+ sourcePrefix: "Source: ",
112
+ restEyes: "Rest your eyes",
113
+ restEyesInstruction: "Look away from the screen and relax.",
114
+ trayNextBreak: "Next: ",
115
+ trayPaused: "Paused — resumes in ~",
116
+ trayTakeMiniNow: "Take a mini break now",
117
+ trayTakeLongNow: "Take a long break now",
118
+ trayPause1Hour: "Pause breaks for 1 hour",
119
+ trayResume: "Resume breaks",
120
+ traySettings: "Settings...",
121
+ trayAbout: "About eye-care",
122
+ trayQuit: "Quit",
123
+ noBreakScheduled: "No break scheduled",
124
+ exercise20_20_20: "20-20-20 Rule",
125
+ exerciseConsciousBlink: "Conscious Blinking",
126
+ exerciseNearFarFocus: "Near-Far Focus Shift",
127
+ exerciseFigure8: "Figure-8 Tracing",
128
+ exercisePalming: "Palming",
129
+ exerciseHorizontalRolls: "Horizontal Eye Rolls",
130
+ guideLook20ft: "Look 20 ft (6 m) away",
131
+ guideYou: "you",
132
+ guideFarL: "far L",
133
+ guideFarR: "far R",
134
+ guideSlowBlink: "Slow blink",
135
+ guideNear: "Near",
136
+ guideFar: "Far",
137
+ guideTrace8: "Trace the 8 with your eyes",
138
+ guideWarmPalms: "Warm palms over closed eyes",
139
+ guideBreathe: "breathe",
140
+ guideSweepLR: "Sweep your gaze left to right",
141
+ guideL: "L",
142
+ guideR: "R",
143
+ guideRestEyes: "Rest your eyes",
144
+ step20_20_20: "Look at an object at least 20 feet (6 m) away — out a window or across the room.",
145
+ stepBlinkClose: "Close your eyes gently and softly for 2 seconds.",
146
+ stepBlinkOpen: "Open slowly. Repeat the slow blink at a relaxed pace.",
147
+ stepNearFocus: "Hold your finger 10-15 inches (25-38 cm) from your eyes and focus on it.",
148
+ stepFarFocus: "Shift focus to an object 20 feet (6 m) or farther away.",
149
+ stepNearBack: "Back to your finger.",
150
+ stepFarBack: "Back to the distant object. Repeat smoothly.",
151
+ stepFigure8Imagine: "Imagine a large figure-8 on the wall about 10 feet (3 m) away.",
152
+ stepFigure8Trace: "Slowly trace it one way with your eyes only (head still).",
153
+ stepFigure8Reverse: "Reverse direction and trace it back.",
154
+ stepPalmRub: "Rub your palms together until they feel warm.",
155
+ stepPalmCover: "Close your eyes and cup your palms over them without pressing on the eyeballs.",
156
+ stepPalmBreathe: "Breathe slowly and relax. Keep the position.",
157
+ stepHoriRight: "Look as far right as comfortable (head still).",
158
+ stepHoriLeft: "Slowly move your gaze to the far left.",
159
+ stepHoriCenter: "Back to center.",
160
+ stepHoriRepeat: "Repeat the slow sweep a few more times.",
161
+ };
162
+
163
+ const ko: Dict = {
164
+ appTitle: "eye-care",
165
+ settingsTitle: "eye-care 설정",
166
+ settingsHint: "휴식 간격과 시간을 조정하세요. 변경사항은 자동 저장됩니다.",
167
+ miniBreakSection: "미니 휴식",
168
+ longBreakSection: "긴 휴식",
169
+ generalSection: "일반",
170
+ enableMiniBreaks: "미니 휴식 사용",
171
+ enableLongBreaks: "긴 휴식 사용",
172
+ intervalMinutes: "간격 (분)",
173
+ durationSeconds: "시간 (초)",
174
+ soundNotifications: "소리 알림",
175
+ strictMode: "엄격 모드 (건너뛰기 금지)",
176
+ strictModeHint: "",
177
+ languageLabel: "언어",
178
+ saved: "저장됨",
179
+ medicalDisclaimer:
180
+ "eye-care는 눈 피로 완화 목적입니다. 의료기기가 아니며 질환을 진단·치료·완치하지 않습니다. 지속적인 눈 불편함이 있다면 안과 전문의와 상담하세요.",
181
+ breakTitleMini: "미니 눈 휴식",
182
+ breakSubtitleMini: "눈에 빠른 휴식을 주세요.",
183
+ breakTitleLong: "긴 눈 휴식",
184
+ breakSubtitleLong: "가이드를 따라 운동해주세요.",
185
+ breakComplete: "휴식 완료",
186
+ breakCompleteSubtitle: "잘했어요. 다시 일어볼까요!",
187
+ skipBreak: "휴식 건너뛰기",
188
+ sourcePrefix: "출처: ",
189
+ restEyes: "눈 휴식",
190
+ restEyesInstruction: "화면에서 눈을 떼고 편안히 쉬세요.",
191
+ trayNextBreak: "다음: ",
192
+ trayPaused: "일시정지 — 약 ",
193
+ trayTakeMiniNow: "지금 미니 휴식하기",
194
+ trayTakeLongNow: "지금 긴 휴식하기",
195
+ trayPause1Hour: "1시간 동안 휴식 일시정지",
196
+ trayResume: "휴식 재개",
197
+ traySettings: "설정...",
198
+ trayAbout: "eye-care 정보",
199
+ trayQuit: "종료",
200
+ noBreakScheduled: "예정된 휴식 없음",
201
+ exercise20_20_20: "20-20-20 규칙",
202
+ exerciseConsciousBlink: "의식적 깜빡임",
203
+ exerciseNearFarFocus: "원근 촛점 교대",
204
+ exerciseFigure8: "8자 추적",
205
+ exercisePalming: "파밍 (손바닥 덮기)",
206
+ exerciseHorizontalRolls: "좌우 눈 운동",
207
+ guideLook20ft: "20피트(6m) 너머 보기",
208
+ guideYou: "나",
209
+ guideFarL: "왼쪽 먼 곳",
210
+ guideFarR: "오른쪽 먼 곳",
211
+ guideSlowBlink: "천천히 깜빡이기",
212
+ guideNear: "가까이",
213
+ guideFar: "멀리",
214
+ guideTrace8: "눈으로 8자를 따라가세요",
215
+ guideWarmPalms: "따뜻한 손바닥으로 눈 덮기",
216
+ guideBreathe: "호흡",
217
+ guideSweepLR: "시선을 좌에서 우로 천천히",
218
+ guideL: "좌",
219
+ guideR: "우",
220
+ guideRestEyes: "눈 휴식",
221
+ step20_20_20: "20피트(6m) 이상 먼 곳을 바라보세요 — 창밖이나 방 반대편.",
222
+ stepBlinkClose: "부드럽게 2초간 눈을 감으세요.",
223
+ stepBlinkOpen: "천천히 뜨세요. 편안한 속도로 반복합니다.",
224
+ stepNearFocus: "손가락을 눈에서 25-38cm 거리에 두고 응시하세요.",
225
+ stepFarFocus: "20피트(6m) 이상 먼 곳의 사물로 초점을 옮기세요.",
226
+ stepNearBack: "다시 손가락으로.",
227
+ stepFarBack: "다시 먼 곳으로. 부드럽게 반복하세요.",
228
+ stepFigure8Imagine: "약 3m 앞 벽에 큰 8자가 있다고 상상하세요.",
229
+ stepFigure8Trace: "머리는 움직이지 말고 눈만으로 한 방향으로 천천히 따라가세요.",
230
+ stepFigure8Reverse: "반대 방향으로 되돌아 가세요.",
231
+ stepPalmRub: "손바닥을 비벼 따뜻하게 만드세요.",
232
+ stepPalmCover: "눈을 감고 손바닥을 눈 위에 덮으세요 (안구를 누르지 마세요).",
233
+ stepPalmBreathe: "천천히 호흡하며 편안히 유지하세요.",
234
+ stepHoriRight: "머리는 그대로 둔 채 시선을 편안한 오른쪽 끝으로.",
235
+ stepHoriLeft: "시선을 천천히 왼쪽 끝으로 옮기세요.",
236
+ stepHoriCenter: "다시 중앙으로.",
237
+ stepHoriRepeat: "느린 좌우 스윕을 몇 번 더 반복하세요.",
238
+ };
239
+
240
+ const DICTS: Record<Language, Dict> = { en, ko };
241
+
242
+ export function t(lang: Language, key: TranslationKey): string {
243
+ return DICTS[lang][key] ?? DICTS.en[key] ?? key;
244
+ }
245
+
246
+ export function getExerciseTranslations(lang: Language): Record<string, ExerciseTranslations> {
247
+ return {
248
+ "20-20-20": {
249
+ name: t(lang, "exercise20_20_20"),
250
+ shortDescription: lang === "ko" ? "20피트(6m) 너머를 20초간 바라보세요." : "Look at something 20 feet away for 20 seconds.",
251
+ steps: [t(lang, "step20_20_20")],
252
+ },
253
+ "conscious-blink": {
254
+ name: t(lang, "exerciseConsciousBlink"),
255
+ shortDescription: lang === "ko" ? "천천히 깜빡여 눈을 적셔주세요." : "Blink slowly to remoisten your eyes.",
256
+ steps: [t(lang, "stepBlinkClose"), t(lang, "stepBlinkOpen")],
257
+ },
258
+ "near-far-focus": {
259
+ name: t(lang, "exerciseNearFarFocus"),
260
+ shortDescription: lang === "ko" ? "가까운 곳과 먼 곳을 번갈아 응시하세요." : "Alternate focus between a near and distant target.",
261
+ steps: [
262
+ t(lang, "stepNearFocus"),
263
+ t(lang, "stepFarFocus"),
264
+ t(lang, "stepNearBack"),
265
+ t(lang, "stepFarBack"),
266
+ ],
267
+ },
268
+ "figure-eight": {
269
+ name: t(lang, "exerciseFigure8"),
270
+ shortDescription: lang === "ko" ? "상상의 8자를 눈으로 추적하며 안근을 이완하세요." : "Trace an imaginary figure-8 with your eyes to relax eye muscles.",
271
+ steps: [
272
+ t(lang, "stepFigure8Imagine"),
273
+ t(lang, "stepFigure8Trace"),
274
+ t(lang, "stepFigure8Reverse"),
275
+ ],
276
+ },
277
+ "palming": {
278
+ name: t(lang, "exercisePalming"),
279
+ shortDescription: lang === "ko" ? "따뜻한 손바닥으로 감은 눈을 덮어 휴식하세요." : "Cover closed eyes with warm palms to relax.",
280
+ steps: [
281
+ t(lang, "stepPalmRub"),
282
+ t(lang, "stepPalmCover"),
283
+ t(lang, "stepPalmBreathe"),
284
+ ],
285
+ },
286
+ "horizontal-rolls": {
287
+ name: t(lang, "exerciseHorizontalRolls"),
288
+ shortDescription: lang === "ko" ? "느린 좌우 눈 운동으로 피로를 풀어주세요." : "Slow horizontal eye movement to relieve fatigue.",
289
+ steps: [
290
+ t(lang, "stepHoriRight"),
291
+ t(lang, "stepHoriLeft"),
292
+ t(lang, "stepHoriCenter"),
293
+ t(lang, "stepHoriRepeat"),
294
+ ],
295
+ },
296
+ };
297
+ }
@@ -0,0 +1,144 @@
1
+ import { app, dialog, ipcMain, BrowserWindow } from "electron";
2
+ import * as path from "path";
3
+ import * as fs from "fs";
4
+ import { BackgroundConfig, BackgroundMode } from "../shared/types";
5
+
6
+ const BUILTIN_BACKGROUNDS = ["sunny-sky", "forest", "sea", "mountains", "sunset"];
7
+
8
+ function userBackgroundsDir(): string {
9
+ return path.join(app.getPath("userData"), "backgrounds");
10
+ }
11
+
12
+ function builtinBackgroundsDir(): string {
13
+ return path.join(__dirname, "..", "renderer", "backgrounds");
14
+ }
15
+
16
+ export function ensureUserBackgroundsDir(): void {
17
+ const dir = userBackgroundsDir();
18
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
19
+ }
20
+
21
+ export function listUserBackgrounds(): string[] {
22
+ const dir = userBackgroundsDir();
23
+ if (!fs.existsSync(dir)) return [];
24
+ return fs
25
+ .readdirSync(dir)
26
+ .filter((f) => /\.(png|jpe?g|webp|gif|svg)$/i.test(f))
27
+ .map((f) => path.parse(f).name);
28
+ }
29
+
30
+ export function listBuiltinBackgrounds(): string[] {
31
+ return BUILTIN_BACKGROUNDS;
32
+ }
33
+
34
+ export function resolveBackgroundPath(name: string, mode: BackgroundMode): string | null {
35
+ if (mode === "builtin") {
36
+ const file = path.join(builtinBackgroundsDir(), name + ".svg");
37
+ return fs.existsSync(file) ? file : null;
38
+ }
39
+ if (mode === "user") {
40
+ const dir = userBackgroundsDir();
41
+ if (!fs.existsSync(dir)) return null;
42
+ const candidates = fs
43
+ .readdirSync(dir)
44
+ .filter((f) => path.parse(f).name === name && /\.(png|jpe?g|webp|gif|svg)$/i.test(f));
45
+ if (candidates.length > 0) return path.join(dir, candidates[0]);
46
+ }
47
+ return null;
48
+ }
49
+
50
+ export function pickRandomBackground(pool: "builtin" | "user" | "all"): string | null {
51
+ let poolNames: { name: string; mode: "builtin" | "user" }[] = [];
52
+ if (pool === "builtin" || pool === "all") {
53
+ poolNames = poolNames.concat(listBuiltinBackgrounds().map((n) => ({ name: n, mode: "builtin" as const })));
54
+ }
55
+ if (pool === "user" || pool === "all") {
56
+ poolNames = poolNames.concat(listUserBackgrounds().map((n) => ({ name: n, mode: "user" as const })));
57
+ }
58
+ if (poolNames.length === 0) return null;
59
+ const pick = poolNames[Math.floor(Math.random() * poolNames.length)];
60
+ const file = resolveBackgroundPath(pick.name, pick.mode);
61
+ return file;
62
+ }
63
+
64
+ const ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"];
65
+
66
+ export function registerBackgroundIpc(getConfig: () => BackgroundConfig, setConfig: (c: BackgroundConfig) => void): void {
67
+ ensureUserBackgroundsDir();
68
+
69
+ ipcMain.handle("bg:listBuiltin", () => listBuiltinBackgrounds());
70
+ ipcMain.handle("bg:listUser", () => listUserBackgrounds());
71
+
72
+ ipcMain.handle("bg:addUser", async (e) => {
73
+ const parentWindow = BrowserWindow.fromWebContents(e.sender);
74
+ const opts: Electron.OpenDialogOptions = {
75
+ title: "Select background image",
76
+ properties: ["openFile", "multiSelections"],
77
+ filters: [
78
+ { name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "svg"] },
79
+ ],
80
+ };
81
+ const result = parentWindow
82
+ ? await dialog.showOpenDialog(parentWindow, opts)
83
+ : await dialog.showOpenDialog(opts);
84
+ if (result.canceled || result.filePaths.length === 0) return { added: [] };
85
+ const dir = userBackgroundsDir();
86
+ const added: string[] = [];
87
+ for (const src of result.filePaths) {
88
+ const ext = path.extname(src).toLowerCase();
89
+ if (!ALLOWED_EXT.includes(ext)) continue;
90
+ const base = path.parse(src).name.replace(/[^\w-]/g, "_");
91
+ const dest = path.join(dir, base + ext);
92
+ try {
93
+ fs.copyFileSync(src, dest);
94
+ added.push(base);
95
+ } catch (err) {
96
+ console.error("Failed to copy background:", err);
97
+ }
98
+ }
99
+ if (added.length > 0) {
100
+ const cfg = getConfig();
101
+ const merged = Array.from(new Set([...cfg.userImages, ...added]));
102
+ setConfig({ ...cfg, userImages: merged });
103
+ }
104
+ return { added };
105
+ });
106
+
107
+ ipcMain.handle("bg:deleteUser", (_e, name: string) => {
108
+ const dir = userBackgroundsDir();
109
+ const candidates = fs
110
+ .readdirSync(dir)
111
+ .filter((f) => path.parse(f).name === name && /\.(png|jpe?g|webp|gif|svg)$/i.test(f));
112
+ for (const c of candidates) {
113
+ try {
114
+ fs.unlinkSync(path.join(dir, c));
115
+ } catch (err) {
116
+ console.error("Failed to delete background:", err);
117
+ }
118
+ }
119
+ const cfg = getConfig();
120
+ setConfig({ ...cfg, userImages: cfg.userImages.filter((n) => n !== name) });
121
+ return true;
122
+ });
123
+
124
+ ipcMain.handle("bg:set", (_e, cfg: BackgroundConfig) => {
125
+ setConfig(cfg);
126
+ return cfg;
127
+ });
128
+
129
+ ipcMain.handle("bg:get", () => getConfig());
130
+
131
+ ipcMain.handle("bg:loadFile", (_e, name: string, mode: BackgroundMode) => {
132
+ const p = resolveBackgroundPath(name, mode);
133
+ if (!p) return null;
134
+ try {
135
+ const data = fs.readFileSync(p);
136
+ const ext = path.extname(p).slice(1).toLowerCase();
137
+ const mime = ext === "svg" ? "image/svg+xml" : "image/" + ext;
138
+ return { mime, data: data.toString("base64") };
139
+ } catch (err) {
140
+ console.error("Failed to load background file:", err);
141
+ return null;
142
+ }
143
+ });
144
+ }