어려운 코딩은 전혀 필요 없습니다. 순서대로 따라오기만 하면 됩니다.
gemini-pet.webp, gemini-pet_green.png)을 이 폴더 안에 넣습니다.{
"name": "gemini-pet",
"productName": "gemini-pet",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder --win nsis --x64"
}
}
const { app, BrowserWindow, ipcMain, Tray, Menu, shell } = require('electron');
const path = require('path');
const http = require('http');
let mainWindow;
let tray = null;
let isMuted = false;
function createWindow() {
mainWindow = new BrowserWindow({
width: 180,
height: 220,
transparent: true,
frame: false,
alwaysOnTop: true,
resizable: false,
skipTaskbar: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
mainWindow.loadFile('index.html');
moveToBottomRight();
}
function moveToBottomRight() {
const { width, height } = require('electron').screen.getPrimaryDisplay().workAreaSize;
mainWindow.setBounds({ x: width - 180, y: height - 220, width: 180, height: 220 });
}
function createTray() {
tray = new Tray(path.join(__dirname, 'gemini-pet_green.png'));
updateTrayMenu();
}
function updateTrayMenu() {
const loginSettings = app.getLoginItemSettings();
const isAutoLaunch = loginSettings.openAtLogin;
const contextMenu = Menu.buildFromTemplate([
{
label: mainWindow && mainWindow.isVisible() ? '펫 숨기기' : '펫 보이기',
click: () => {
if(mainWindow) { mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show(); }
updateTrayMenu();
}
},
{ label: '화면오른쪽아래로 옮기기', click: () => moveToBottomRight() },
{
label: isMuted ? '알림 다시 켜기' : '알림쉬기(조용히)',
click: () => { isMuted = !isMuted; updateTrayMenu(); }
},
{ type: 'separator' },
{
label: 'Window시작시 자동실행',
type: 'checkbox',
checked: isAutoLaunch,
click: (menuItem) => {
app.setLoginItemSettings({ openAtLogin: menuItem.checked, path: app.getPath('exe') });
updateTrayMenu();
}
},
{ label: '펫폴더 열기', click: () => shell.openPath(__dirname) },
{ type: 'separator' },
{ label: '종료', click: () => app.quit() }
]);
tray.setToolTip('gemini-pet');
tray.setContextMenu(contextMenu);
}
function startDirectReceiver() {
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
if (req.method === 'POST') {
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
req.on('end', () => {
try {
const data = JSON.parse(body);
if (mainWindow && !isMuted) { mainWindow.webContents.send('update-status', data); }
} catch (e) { console.error(e); }
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
});
}
});
server.listen(5002, '127.0.0.1', () => {});
}
app.whenReady().then(() => {
createWindow();
createTray();
startDirectReceiver();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { app.quit(); }
});
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>gemini-pet</title>
<style>
body {
margin: 0;
overflow: hidden;
font-family: 'Jua', 'Cafe24 Ssurround', 'NanumSquareRound', 'Pretendard', sans-serif;
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100vh;
background-color: transparent;
}
#bubble {
width: 140px;
min-height: 35px;
background-color: #CCCCCC;
border-radius: 12px;
padding: 8px;
margin-bottom: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
word-break: keep-all;
line-height: 1.3;
font-weight: bold;
font-size: 11px;
color: #000;
position: relative;
}
#bubble::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
border-width: 8px 8px 0;
border-style: solid;
border-color: inherit;
border-left-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
}
#petImage {
width: 100px;
height: 75px;
-webkit-app-region: drag;
cursor: move;
}
</style>
</head>
<body>
<div id="bubble" style="border-top-color: #CCCCCC;">
<span id="message">💤대기중 입니다</span>
</div>
<img id="petImage" src="gemini-pet.webp" alt="gemini-pet" draggable="false">
<script src="renderer.js"></script>
</body>
</html>
const { ipcRenderer } = require('electron');
const bubble = document.getElementById('bubble');
const messageSpan = document.getElementById('message');
const COLORS = {
대기: '#CCCCCC',
질문: '#E6E6FA',
완료: '#4CAF50'
};
let currentTitle = '';
let currentState = '대기';
let displayInterval = null;
let isShowingTitle = false;
function setBubbleStyle(color) {
bubble.style.backgroundColor = color;
bubble.style.borderTopColor = color;
}
function updateDisplay() {
if (displayInterval) {
clearInterval(displayInterval);
displayInterval = null;
}
if (currentState === '대기') {
setBubbleStyle(COLORS.대기);
messageSpan.textContent = "💤대기중 입니다";
return;
}
const isDone = currentState === '완료';
setBubbleStyle(isDone ? COLORS.완료 : COLORS.질문);
const titleText = currentTitle;
const statusText = isDone
? `🎉 ${titleText} 작업이 완료되었습니다`
: `🙋♀️ ${titleText} 질문있습니다!`;
isShowingTitle = true;
messageSpan.textContent = titleText;
displayInterval = setInterval(() => {
isShowingTitle = !isShowingTitle;
messageSpan.textContent = isShowingTitle ? titleText : statusText;
}, 1000);
}
ipcRenderer.on('update-status', (event, data) => {
currentTitle = data.title || '작업';
if (data.state === 'generating') {
currentState = '질문';
} else if (data.state === 'done') {
currentState = '완료';
} else {
currentState = '대기';
}
updateDisplay();
});
{
"manifest_version": 3,
"name": "gemini-pet 통합 감지기",
"version": "1.0.0",
"description": "gemini의 답변 상태를 바탕화면 펫에 알립니다.",
"permissions": ["activeTab", "scripting"],
"host_permissions": ["https://gemini.google.com/*", "http://127.0.0.1:5002/*"],
"content_scripts": [
{
"matches": ["https://gemini.google.com/*"],
"js": ["content.js"]
}
]
}
let isGenerating = false;
// 1. 강제 기상 나팔: 스크립트가 켜지자마자 펫을 즉시 깨웁니다.
fetch('http://127.0.0.1:5002', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'gemini', status: 'done', state: 'done' })
}).catch(err => console.log('Error:', err));
// 2. 0.3초 단위 초정밀 감시 시작
setInterval(() => {
const stopButton = document.querySelector('button[aria-label*="중지"], button[aria-label*="Stop"]');
const currentlyGenerating = !!stopButton;
if (currentlyGenerating !== isGenerating) {
isGenerating = currentlyGenerating;
const currentStatus = isGenerating ? 'generating' : 'done';
// 서버 통신 오류 방지를 위해 양쪽 변수(status, state) 모두 전송
fetch('http://127.0.0.1:5002', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'gemini', status: currentStatus, state: currentStatus })
}).catch(err => console.log('Error:', err));
}
}, 300);