@@ -7,13 +7,10 @@ import * as path from 'path';
77import * as fs from 'fs/promises' ;
88import { fileURLToPath } from 'url' ;
99import { createRequire } from 'module' ;
10- import { execFile } from 'child_process' ;
11- import { promisify } from 'util' ;
12- import { SUPPORT_EMAIL } from './constants' ;
10+ import { FEEDBACK_FORM_ENTRY_IDS , FEEDBACK_FORM_URL } from './constants' ;
1311
1412// Create require function for ES modules
1513const require = createRequire ( import . meta. url ) ;
16- const execFileAsync = promisify ( execFile ) ;
1714
1815const MAX_LOG_BUFFER_ENTRIES = 2000 ;
1916const appLogBuffer : string [ ] = [ ] ;
@@ -130,6 +127,11 @@ const __dirname = path.dirname(__filename);
130127// Set the app name - this is displayed in menus and dialogs
131128app . name = 'Paycheck Planner' ;
132129
130+ const hasSingleInstanceLock = app . requestSingleInstanceLock ( ) ;
131+ if ( ! hasSingleInstanceLock ) {
132+ app . quit ( ) ;
133+ }
134+
133135// Configure the About panel (macOS only)
134136if ( process . platform === 'darwin' ) {
135137 app . setAboutPanelOptions ( {
@@ -168,6 +170,72 @@ let welcomeWindow: BrowserWindow | null = null;
168170let mainWindow : BrowserWindow | null = null ;
169171// Track if the app is quitting to avoid reopening welcome window
170172let isQuitting = false ;
173+ // Track file requested via OS open-file/Open With integration.
174+ let pendingExternalBudgetFilePath : string | null = null ;
175+
176+ function isBudgetFilePath ( filePath : string ) : boolean {
177+ const extension = path . extname ( filePath ) . toLowerCase ( ) ;
178+ return extension === '.budget' ;
179+ }
180+
181+ function extractBudgetFilePathFromArgv ( argv : string [ ] ) : string | null {
182+ for ( const arg of argv ) {
183+ if ( ! arg || arg . startsWith ( '-' ) ) continue ;
184+ if ( isBudgetFilePath ( arg ) ) return arg ;
185+ }
186+ return null ;
187+ }
188+
189+ function getPrimaryWindow ( ) : BrowserWindow | null {
190+ const focused = BrowserWindow . getFocusedWindow ( ) ;
191+ if ( focused && ! focused . isDestroyed ( ) ) return focused ;
192+ if ( hasLiveWelcomeWindow ( ) ) return welcomeWindow ;
193+
194+ const firstPlanWindow = Array . from ( openWindows ) [ 0 ] ;
195+ if ( firstPlanWindow && ! firstPlanWindow . isDestroyed ( ) ) return firstPlanWindow ;
196+
197+ const allWindows = BrowserWindow . getAllWindows ( ) ;
198+ if ( allWindows . length > 0 && ! allWindows [ 0 ] . isDestroyed ( ) ) return allWindows [ 0 ] ;
199+
200+ return null ;
201+ }
202+
203+ function dispatchExternalBudgetOpen ( filePath : string ) {
204+ const targetWindow = getPrimaryWindow ( ) ;
205+ if ( ! targetWindow ) {
206+ pendingExternalBudgetFilePath = filePath ;
207+ return ;
208+ }
209+
210+ const sendOpenFileEvent = ( ) => {
211+ targetWindow . webContents . send ( 'menu:open-budget-file' , filePath ) ;
212+ pendingExternalBudgetFilePath = null ;
213+ targetWindow . show ( ) ;
214+ targetWindow . focus ( ) ;
215+ } ;
216+
217+ if ( targetWindow . webContents . isLoading ( ) ) {
218+ targetWindow . webContents . once ( 'did-finish-load' , sendOpenFileEvent ) ;
219+ } else {
220+ sendOpenFileEvent ( ) ;
221+ }
222+ }
223+
224+ function handleExternalOpenFile ( filePath : string ) {
225+ if ( ! filePath || ! isBudgetFilePath ( filePath ) ) {
226+ debug ( 'Ignoring non-budget external file open request:' , filePath ) ;
227+ return ;
228+ }
229+
230+ debug ( 'Handling external file open request:' , filePath ) ;
231+ dispatchExternalBudgetOpen ( filePath ) ;
232+ }
233+
234+ // On macOS this fires when users double-click associated files in Finder.
235+ app . on ( 'open-file' , ( event , filePath ) => {
236+ event . preventDefault ( ) ;
237+ handleExternalOpenFile ( filePath ) ;
238+ } ) ;
171239
172240function hasLiveWelcomeWindow ( ) : boolean {
173241 return ! ! welcomeWindow && ! welcomeWindow . isDestroyed ( ) ;
@@ -470,7 +538,12 @@ function createWelcomeWindow(skipSessionRestore = false, windowState?: { width?:
470538
471539 welcomeWindow . on ( 'closed' , ( ) => {
472540 welcomeWindow = null ;
473- // Don't auto-create another welcome window
541+ // If no plan windows are open, quit the app instead of staying open with no windows
542+ if ( openWindows . size === 0 && ! isQuitting ) {
543+ isQuitting = true ;
544+ app . quit ( ) ;
545+ }
546+ // Otherwise, don't auto-create another welcome window
474547 // Only plan windows trigger welcome window creation
475548 } ) ;
476549}
@@ -506,6 +579,12 @@ async function createWindow() {
506579 debug ( 'Creating welcome window with default size' ) ;
507580 createWelcomeWindow ( ) ;
508581
582+ const startupFilePath = pendingExternalBudgetFilePath || extractBudgetFilePathFromArgv ( process . argv ) ;
583+ if ( startupFilePath ) {
584+ pendingExternalBudgetFilePath = startupFilePath ;
585+ dispatchExternalBudgetOpen ( startupFilePath ) ;
586+ }
587+
509588 // Create application menu with File and Edit options
510589 createApplicationMenu ( ) ;
511590
@@ -958,6 +1037,21 @@ app.on('before-quit', () => {
9581037// Wait for Electron to be ready, then create the window
9591038app . whenReady ( ) . then ( createWindow ) ;
9601039
1040+ app . on ( 'second-instance' , ( _event , argv ) => {
1041+ const openedFile = extractBudgetFilePathFromArgv ( argv ) ;
1042+ if ( openedFile ) {
1043+ handleExternalOpenFile ( openedFile ) ;
1044+ return ;
1045+ }
1046+
1047+ const targetWindow = getPrimaryWindow ( ) ;
1048+ if ( targetWindow ) {
1049+ if ( targetWindow . isMinimized ( ) ) targetWindow . restore ( ) ;
1050+ targetWindow . show ( ) ;
1051+ targetWindow . focus ( ) ;
1052+ }
1053+ } ) ;
1054+
9611055// On macOS, apps typically stay open even when all windows are closed
9621056// On Windows/Linux, show welcome window when all windows are closed
9631057app . on ( 'window-all-closed' , ( ) => {
@@ -1125,7 +1219,7 @@ ipcMain.handle('export-pdf', async (event, filePath: string, pdfData: Uint8Array
11251219
11261220/**
11271221 * Submit feedback
1128- * Opens a prefilled support email in the user's default email client
1222+ * Opens a Google Form for feedback collection.
11291223 */
11301224ipcMain . handle ( 'submit-feedback' , async ( _event , payload : {
11311225 email ?: string ;
@@ -1142,103 +1236,45 @@ ipcMain.handle('submit-feedback', async (_event, payload: {
11421236 } ;
11431237} ) => {
11441238 try {
1145- const timestamp = new Date ( ) ;
1146- const id = `${ timestamp . getTime ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
1147- const feedbackTempDir = path . join ( app . getPath ( 'temp' ) , 'paycheck-planner-feedback' , id ) ;
1148- await fs . mkdir ( feedbackTempDir , { recursive : true } ) ;
1149-
1150- const attachmentPaths : string [ ] = [ ] ;
1151-
1152- if ( payload . screenshot ?. dataUrl ) {
1153- const match = payload . screenshot . dataUrl . match ( / ^ d a t a : ( .+ ) ; b a s e 6 4 , ( .* ) $ / ) ;
1154- if ( match ) {
1155- const mimeType = match [ 1 ] || payload . screenshot . mimeType || 'image/png' ;
1156- const ext = mimeType . split ( '/' ) [ 1 ] || 'png' ;
1157- const screenshotPath = path . join ( feedbackTempDir , `screenshot.${ ext } ` ) ;
1158- await fs . writeFile ( screenshotPath , Buffer . from ( match [ 2 ] , 'base64' ) ) ;
1159- attachmentPaths . push ( screenshotPath ) ;
1160- }
1239+ if ( ! FEEDBACK_FORM_URL . trim ( ) ) {
1240+ return {
1241+ success : false ,
1242+ error : 'Feedback form URL is not configured. Set FEEDBACK_FORM_URL in the Electron environment.' ,
1243+ } ;
11611244 }
11621245
1163- const formattedDetailsPath = path . join ( feedbackTempDir , 'feedback-details.html' ) ;
1164- const detailsHtmlDoc = `<!doctype html><html><head><meta charset="utf-8"><title>Feedback Details</title></head><body>${ payload . messageHtml } </body></html>` ;
1165- await fs . writeFile ( formattedDetailsPath , detailsHtmlDoc , 'utf-8' ) ;
1166- attachmentPaths . push ( formattedDetailsPath ) ;
1246+ const formUrl = new URL ( FEEDBACK_FORM_URL ) ;
1247+ const prefillParams = new URLSearchParams ( formUrl . search ) ;
1248+ prefillParams . set ( 'usp' , 'pp_url' ) ;
11671249
1168- if ( payload . includeDiagnostics && payload . diagnostics ) {
1169- const diagnosticsPath = path . join ( feedbackTempDir , 'diagnostics.json' ) ;
1170- await fs . writeFile ( diagnosticsPath , JSON . stringify ( payload . diagnostics , null , 2 ) , 'utf-8' ) ;
1171- attachmentPaths . push ( diagnosticsPath ) ;
1172- }
1250+ const appendPrefill = ( entryId : string , value ?: string ) => {
1251+ if ( ! entryId || ! value || ! value . trim ( ) ) return ;
1252+ prefillParams . set ( entryId , value . trim ( ) ) ;
1253+ } ;
11731254
1174- const logPath = path . join ( feedbackTempDir , 'feedback-log.txt' ) ;
1175- const consoleLogHeader = [
1176- `Feedback Timestamp: ${ timestamp . toISOString ( ) } ` ,
1177- `Category: ${ payload . category } ` ,
1178- `From: ${ payload . email || 'Not provided' } ` ,
1179- `Subject: ${ payload . subject } ` ,
1180- '' ,
1181- '--- App Debug/Info Console Output ---' ,
1182- ] ;
1183- const consoleLogContent = appLogBuffer . length > 0 ? appLogBuffer . join ( '\n' ) : 'No captured debug/info logs available.' ;
1184- await fs . writeFile ( logPath , `${ consoleLogHeader . join ( '\n' ) } \n${ consoleLogContent } \n` , 'utf-8' ) ;
1185- attachmentPaths . push ( logPath ) ;
1186-
1187- const lines : string [ ] = [
1188- `Category: ${ payload . category } ` ,
1189- `From: ${ payload . email || 'Not provided' } ` ,
1190- '' ,
1191- 'Message:' ,
1192- payload . messageText ,
1193- ] ;
1194-
1195- lines . push ( '' , 'Attachment files have been prepared by the app.' ) ;
1196-
1197- if ( process . platform === 'darwin' && attachmentPaths . length > 0 ) {
1198- const escapeAppleScript = ( value : string ) : string =>
1199- value . replace ( / \\ / g, '\\\\' ) . replace ( / " / g, '\\"' ) ;
1200-
1201- const bodyText = `${ lines . join ( '\n' ) } \n` ;
1202- const scriptLines = [
1203- 'tell application "Mail"' ,
1204- `set newMessage to make new outgoing message with properties {subject:"${ escapeAppleScript ( `[Paycheck Planner Feedback] ${ payload . subject } ` ) } ", content:"${ escapeAppleScript ( bodyText ) } ", visible:true}` ,
1205- 'tell newMessage' ,
1206- `make new to recipient at end of to recipients with properties {address:"${ escapeAppleScript ( SUPPORT_EMAIL ) } "}` ,
1207- 'end tell' ,
1208- ] ;
1209-
1210- attachmentPaths . forEach ( ( attachmentPath ) => {
1211- scriptLines . push (
1212- `tell content of newMessage to make new attachment with properties {file name:POSIX file "${ escapeAppleScript ( attachmentPath ) } "} at after the last paragraph`
1213- ) ;
1214- } ) ;
1255+ const categoryLabelMap : Record < typeof payload . category , string > = {
1256+ bug : 'Bug' ,
1257+ feature : 'Feature request' ,
1258+ ui : 'UI improvement' ,
1259+ performance : 'Performance issue' ,
1260+ other : 'Other' ,
1261+ } ;
12151262
1216- scriptLines . push ( 'activate' ) ;
1217- scriptLines . push ( 'end tell' ) ;
1218-
1219- const scriptPath = path . join ( feedbackTempDir , 'create-mail-draft.applescript' ) ;
1220- await fs . writeFile ( scriptPath , scriptLines . join ( '\n' ) , 'utf-8' ) ;
1221-
1222- try {
1223- await execFileAsync ( 'osascript' , [ scriptPath ] ) ;
1224- return { success : true } ;
1225- } catch ( error ) {
1226- console . error ( 'Error creating Apple Mail draft with attachments:' , error ) ;
1227- }
1228- }
1263+ // Prefill the Google Form "Details" question with exactly what the user typed.
1264+ const details = payload . messageText . trim ( ) . slice ( 0 , 5000 ) ;
12291265
1230- // Fallback path for non-macOS or if Apple Mail automation fails.
1231- await shell . openPath ( feedbackTempDir ) ;
1266+ appendPrefill ( FEEDBACK_FORM_ENTRY_IDS . email , payload . email ) ;
1267+ appendPrefill ( FEEDBACK_FORM_ENTRY_IDS . category , categoryLabelMap [ payload . category ] ) ;
1268+ appendPrefill ( FEEDBACK_FORM_ENTRY_IDS . subject , payload . subject ) ;
1269+ appendPrefill ( FEEDBACK_FORM_ENTRY_IDS . details , details ) ;
12321270
1233- const encodedSubject = encodeURIComponent ( `[Paycheck Planner Feedback] ${ payload . subject } ` ) ;
1234- const encodedBody = encodeURIComponent ( lines . join ( '\n' ) ) ;
1235- const mailtoUrl = `mailto:${ SUPPORT_EMAIL } ?subject=${ encodedSubject } &body=${ encodedBody } ` ;
1271+ formUrl . search = prefillParams . toString ( ) ;
12361272
1237- await shell . openExternal ( mailtoUrl ) ;
1273+ await shell . openExternal ( formUrl . toString ( ) ) ;
12381274
12391275 return { success : true } ;
12401276 } catch ( error ) {
1241- console . error ( 'Error opening feedback email :' , error ) ;
1277+ console . error ( 'Error opening feedback form :' , error ) ;
12421278 return { success : false , error : ( error as Error ) . message } ;
12431279 }
12441280} ) ;
0 commit comments