Skip to content

Commit fae6cdd

Browse files
authored
Merge pull request #5 from pladisdev/development
Added an MSI installer!
2 parents d1c85cc + 10a94cb commit fae6cdd

28 files changed

Lines changed: 1067 additions & 40242 deletions

.github/workflows/build-and-release.yml

Lines changed: 112 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -44,30 +44,28 @@ jobs:
4444
- name: Get version from git tags
4545
id: get_version
4646
run: |
47-
# Try to get version from latest git tag
47+
# Get version from backend/version.py as the source of truth
48+
$fileVersion = "1.0.0" # Default fallback
49+
if (Test-Path "backend/version.py") {
50+
$versionContent = Get-Content "backend/version.py" | Select-String -Pattern '__version__\s*=\s*[''"]([^''"]+)[''"]'
51+
if ($versionContent) {
52+
$fileVersion = $versionContent.Matches.Groups[1].Value
53+
Write-Host "Found version in backend/version.py: $fileVersion"
54+
}
55+
}
56+
57+
# Also check latest git tag for reference
4858
$gitVersion = git describe --tags --abbrev=0 2>$null
4959
if ($LASTEXITCODE -eq 0 -and $gitVersion) {
50-
# Clean up version (remove 'v' prefix if present)
51-
$version = $gitVersion -replace '^v', ''
52-
Write-Host "Found git tag version: $version"
53-
} else {
54-
# Fallback to version from backend/version.py
55-
if (Test-Path "backend/version.py") {
56-
$versionContent = Get-Content "backend/version.py" | Select-String -Pattern '__version__\s*=\s*[''"]([^''"]+)[''"]'
57-
if ($versionContent) {
58-
$version = $versionContent.Matches.Groups[1].Value
59-
Write-Host "Using version from backend/version.py: $version"
60-
} else {
61-
$version = "1.0.0"
62-
Write-Host "No version found, using default: $version"
63-
}
64-
} else {
65-
$version = "1.0.0"
66-
Write-Host "No version found, using default: $version"
67-
}
60+
$gitVersion = $gitVersion -replace '^v', ''
61+
Write-Host "Latest git tag version: $gitVersion"
6862
}
6963
70-
# Update backend/version.py with the version
64+
# Use the version from backend/version.py as the authoritative source
65+
$version = $fileVersion
66+
Write-Host "Using version: $version"
67+
68+
# Update backend/version.py to ensure it's consistent
7169
@"
7270
# Auto-generated version file
7371
# This file is automatically updated during the build process
@@ -117,6 +115,56 @@ jobs:
117115
Write-Host "Executable size: $([math]::Round($size, 2)) MB"
118116
shell: powershell
119117

118+
- name: Install WiX Toolset v4
119+
run: |
120+
Write-Host "Installing WiX Toolset v4..."
121+
dotnet tool install --global wix
122+
123+
# Ensure WiX is in PATH
124+
$env:PATH = "$env:USERPROFILE\.dotnet\tools;$env:PATH"
125+
126+
Write-Host "Verifying WiX installation..."
127+
wix --version
128+
129+
Write-Host "Installing WiX UI extension globally..."
130+
wix extension add --global WixToolset.UI.wixext
131+
132+
Write-Host "Verifying UI extension..."
133+
wix extension list
134+
wix extension list --global
135+
136+
Write-Host "WiX installation complete"
137+
shell: powershell
138+
139+
- name: Generate installer images
140+
run: |
141+
Write-Host "Generating custom installer images..."
142+
pip install pillow
143+
python deployment/create_installer_images.py
144+
shell: powershell
145+
146+
- name: Build MSI Installer
147+
run: python deployment/build_msi.py
148+
env:
149+
VITE_APP_VERSION: ${{ steps.get_version.outputs.app_version }}
150+
151+
- name: Verify MSI exists
152+
id: msi_info
153+
run: |
154+
$msiPath = Get-ChildItem -Path "dist/msi" -Filter "*.msi" | Select-Object -First 1
155+
if (!$msiPath) {
156+
Write-Error "MSI build failed: No .msi file found in dist/msi directory"
157+
exit 1
158+
}
159+
Write-Host "[SUCCESS] MSI build successful: $($msiPath.Name) found"
160+
$size = $msiPath.Length / 1MB
161+
Write-Host "MSI size: $([math]::Round($size, 2)) MB"
162+
163+
# Store MSI info for later steps
164+
echo "msi_path=$($msiPath.FullName)" >> $env:GITHUB_OUTPUT
165+
echo "msi_name=$($msiPath.Name)" >> $env:GITHUB_OUTPUT
166+
shell: powershell
167+
120168
- name: Get version info
121169
id: version
122170
run: |
@@ -145,7 +193,9 @@ jobs:
145193
uses: actions/upload-artifact@v4
146194
with:
147195
name: ChatYapper-${{ steps.version.outputs.version }}
148-
path: dist/ChatYapper.exe
196+
path: |
197+
dist/ChatYapper.exe
198+
dist/msi/*.msi
149199
retention-days: 7
150200

151201
- name: Comment PR with build status
@@ -154,19 +204,28 @@ jobs:
154204
with:
155205
script: |
156206
const fs = require('fs');
157-
const stats = fs.statSync('dist/ChatYapper.exe');
158-
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
207+
const path = require('path');
208+
209+
const exeStats = fs.statSync('dist/ChatYapper.exe');
210+
const exeSizeMB = (exeStats.size / (1024 * 1024)).toFixed(2);
211+
212+
// Find MSI file
213+
const msiDir = 'dist/msi';
214+
const msiFiles = fs.readdirSync(msiDir).filter(f => f.endsWith('.msi'));
215+
const msiFile = msiFiles.length > 0 ? msiFiles[0] : null;
216+
const msiStats = msiFile ? fs.statSync(path.join(msiDir, msiFile)) : null;
217+
const msiSizeMB = msiStats ? (msiStats.size / (1024 * 1024)).toFixed(2) : 'N/A';
159218
160219
const comment = `## ✅ Build Successful
161220
162-
**Executable:** \`ChatYapper.exe\`
163-
**Size:** ${sizeMB} MB
221+
**Executable:** \`ChatYapper.exe\` (${exeSizeMB} MB)
222+
**MSI Installer:** \`${msiFile || 'Not found'}\` (${msiSizeMB} MB)
164223
**App Version:** \`${{ steps.get_version.outputs.app_version }}\`
165224
**Build ID:** \`${{ steps.version.outputs.version }}\`
166225
**Commit:** ${{ github.sha }}
167226
168-
The executable has been built and is ready for testing.
169-
Download the artifact from the workflow run to test before merging.
227+
Both the standalone executable and MSI installer have been built and are ready for testing.
228+
Download the artifacts from the workflow run to test before merging.
170229
171230
Once merged to \`main\`, an official release will be created automatically with tag \`v${{ steps.get_version.outputs.app_version }}\`.`;
172231
@@ -179,13 +238,10 @@ jobs:
179238
180239
- name: Create Release (on merge to main)
181240
if: steps.version.outputs.is_release == 'true'
182-
id: create_release
183-
uses: actions/create-release@v1
184-
env:
185-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
241+
uses: softprops/action-gh-release@v1
186242
with:
187243
tag_name: ${{ steps.version.outputs.version }}
188-
release_name: Chat Yapper ${{ steps.version.outputs.version }}
244+
name: Chat Yapper ${{ steps.version.outputs.version }}
189245
body: |
190246
## Chat Yapper ${{ steps.version.outputs.version }}
191247
@@ -197,34 +253,45 @@ jobs:
197253
**Build Date:** ${{ github.event.head_commit.timestamp }}
198254
199255
### Download
200-
Download `ChatYapper.exe` below to run the application.
256+
Choose one of the following installation methods:
257+
258+
#### MSI Installer (Recommended)
259+
- Download `${{ steps.msi_info.outputs.msi_name }}` for a traditional Windows installation
260+
- Double-click to install to Program Files
261+
- Creates Start Menu and Desktop shortcuts
262+
- Easily uninstall via Windows Settings
263+
264+
#### Standalone Executable
265+
- Download `ChatYapper.exe` for a portable version
266+
- No installation required
267+
- Run directly from any location
201268
202269
### Installation
270+
271+
**MSI Installer:**
272+
1. Download the `.msi` file below
273+
2. Double-click to run the installer
274+
3. Follow the installation wizard
275+
4. Launch from Start Menu or Desktop shortcut
276+
277+
**Standalone Executable:**
203278
1. Download `ChatYapper.exe`
204279
2. Run the executable
205280
3. The application will start on `http://localhost:8008`
206281
207282
### System Requirements
208283
- Windows 10 or later
209-
- No additional dependencies required (all bundled in the executable)
284+
- No additional dependencies required (all bundled)
210285
211286
### Support
212287
- **Issues:** https://github.com/${{ github.repository }}/issues
213288
- **Discussions:** https://github.com/${{ github.repository }}/discussions
289+
files: |
290+
dist/ChatYapper.exe
291+
${{ steps.msi_info.outputs.msi_path }}
214292
draft: false
215293
prerelease: false
216294

217-
- name: Upload Release Asset
218-
if: steps.version.outputs.is_release == 'true'
219-
uses: actions/upload-release-asset@v1
220-
env:
221-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
222-
with:
223-
upload_url: ${{ steps.create_release.outputs.upload_url }}
224-
asset_path: ./dist/ChatYapper.exe
225-
asset_name: ChatYapper.exe
226-
asset_content_type: application/vnd.microsoft.portable-executable
227-
228295
- name: Notify release created
229296
if: steps.version.outputs.is_release == 'true'
230297
run: |

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,5 @@ coverage.xml
165165
*.cover
166166

167167

168+
.wix/
169+

ChatYapper.exe

-51.3 MB
Binary file not shown.

backend/app.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from datetime import datetime
88
from typing import Dict, Any, List
99
from collections import defaultdict
10+
import builtins
1011

1112
# Load environment variables from .env file
1213
from dotenv import load_dotenv
@@ -204,8 +205,18 @@ async def log_requests(request: Request, call_next):
204205

205206
# Serve generated audio files under /audio
206207
# Use AUDIO_DIR from TTS module to ensure consistency
208+
logger.info(f"=== MOUNTING AUDIO DIRECTORY ===")
207209
logger.info(f"Audio directory: {AUDIO_DIR}")
210+
logger.info(f"Audio directory exists: {os.path.isdir(AUDIO_DIR)}")
211+
if os.path.isdir(AUDIO_DIR):
212+
try:
213+
files = os.listdir(AUDIO_DIR)
214+
logger.info(f"Files in audio directory: {len(files)} files")
215+
except Exception as e:
216+
logger.error(f"Error listing audio directory: {e}")
217+
208218
app.mount("/audio", StaticFiles(directory=AUDIO_DIR), name="audio")
219+
logger.info(f"=== AUDIO MOUNT COMPLETE ===")
209220

210221

211222
# Debug: List files in the public directory
@@ -232,16 +243,33 @@ def unregister(self, ws: WebSocket):
232243
if ws in self.clients:
233244
self.clients.remove(ws)
234245
async def broadcast(self, payload: Dict[str, Any]):
246+
logger.debug(f"Hub.broadcast called with payload type: {payload.get('type')}")
247+
logger.debug(f"Broadcasting to {len(self.clients)} clients")
235248
dead = []
249+
sent_count = 0
236250
for ws in self.clients:
237251
try:
238252
await ws.send_text(json.dumps(payload))
239-
except Exception:
253+
sent_count += 1
254+
logger.debug(f"Sent message to client {sent_count}/{len(self.clients)}")
255+
except Exception as e:
256+
logger.warning(f"Failed to send to client: {e}")
240257
dead.append(ws)
241258
for d in dead:
242259
self.unregister(d)
260+
logger.debug(f"Broadcast complete: {sent_count} succeeded, {len(dead)} failed")
261+
262+
# Use a singleton pattern to prevent hub from being recreated on module reload
263+
# This is critical for .exe builds where imports can cause module reinitialization
264+
# We store the hub in builtins which is truly global and survives module reloads
265+
if not hasattr(builtins, '_chatyapper_hub_instance'):
266+
logger.info("Creating new Hub instance (first initialization)")
267+
hub = Hub()
268+
builtins._chatyapper_hub_instance = hub
269+
else:
270+
hub = builtins._chatyapper_hub_instance
271+
logger.info(f"Hub already exists with {len(hub.clients)} clients (module reload detected)")
243272

244-
hub = Hub()
245273
async def broadcast_avatar_slots():
246274
await hub.broadcast({
247275
"type": "avatar_slots_updated",
@@ -1142,6 +1170,15 @@ async def process_tts_message(evt: Dict[str, Any]):
11421170

11431171
audio_url = f"/audio/{os.path.basename(path)}"
11441172

1173+
# Debug logging for .exe troubleshooting
1174+
logger.info(f"=== TTS AUDIO GENERATED ===")
1175+
logger.info(f"Audio file path: {path}")
1176+
logger.info(f"Audio file exists: {os.path.exists(path)}")
1177+
logger.info(f"Audio file size: {os.path.getsize(path) if os.path.exists(path) else 'N/A'} bytes")
1178+
logger.info(f"Audio URL: {audio_url}")
1179+
logger.info(f"Audio duration: {audio_duration}s")
1180+
logger.info(f"AUDIO_DIR: {AUDIO_DIR}")
1181+
11451182
# Create base payload
11461183
voice_info = {
11471184
"id": selected_voice.id,
@@ -1176,7 +1213,15 @@ async def process_tts_message(evt: Dict[str, Any]):
11761213
})
11771214

11781215
logger.info(f"Broadcasting TTS with slot {target_slot['id']} to {len(hub.clients)} clients")
1216+
logger.info(f"=== BROADCASTING WEBSOCKET MESSAGE ===")
1217+
logger.info(f"Message type: play")
1218+
logger.info(f"Target slot: {target_slot['id']}")
1219+
logger.info(f"Audio URL in payload: {enhanced_payload.get('audioUrl')}")
1220+
logger.info(f"Connected WebSocket clients: {len(hub.clients)}")
1221+
logger.info(f"Payload keys: {list(enhanced_payload.keys())}")
1222+
11791223
await hub.broadcast(enhanced_payload)
1224+
logger.info(f"=== BROADCAST COMPLETE ===")
11801225
else:
11811226
# No slots available - queue the message
11821227
logger.info(f"All slots busy, queuing TTS for {username}")

backend/modules/persistent_data.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,20 @@ def get_user_data_dir():
5858
YOUTUBE_REDIRECT_URI = f"http://localhost:{os.environ.get('PORT', 8000)}/auth/youtube/callback"
5959
YOUTUBE_SCOPE = "https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/youtube.force-ssl"
6060

61-
AUDIO_DIR = os.environ.get("AUDIO_DIR", os.path.join(find_project_root(), "audio"))
61+
# Audio directory - use persistent location for .exe compatibility
62+
# In development, use project root's audio folder
63+
# When packaged as .exe, use user data directory
64+
if os.environ.get("AUDIO_DIR"):
65+
AUDIO_DIR = os.environ.get("AUDIO_DIR")
66+
elif getattr(sys, 'frozen', False):
67+
# Running as compiled executable - use user data directory
68+
AUDIO_DIR = os.path.join(USER_DATA_DIR, "audio")
69+
else:
70+
# Running from source - use project root
71+
AUDIO_DIR = os.path.join(find_project_root(), "audio")
72+
6273
os.makedirs(AUDIO_DIR, exist_ok=True)
74+
logger.info(f"Audio directory set to: {AUDIO_DIR}")
6375

6476
# Database setup
6577
engine = create_engine(f"sqlite:///{DB_PATH}", echo=False, connect_args={"check_same_thread": False})

0 commit comments

Comments
 (0)