@@ -64,6 +64,10 @@ function getDriveUploadedDir() {
6464 return path . join ( getDriveInboxDir ( ) , '_uploaded' ) ;
6565}
6666
67+ function getDriveFailedDir ( ) {
68+ return path . join ( getDriveInboxDir ( ) , '_failed' ) ;
69+ }
70+
6771function getConfiguredDriveLetter ( ) {
6872 const raw = String ( store . get ( 'drive.letter' , 'F' ) || 'F' ) . trim ( ) ;
6973 const letter = raw . replace ( ':' , '' ) . toUpperCase ( ) . slice ( 0 , 1 ) ;
@@ -142,6 +146,141 @@ async function ensureDriveDirs() {
142146 fs . mkdirSync ( getDriveInboxDir ( ) , { recursive : true } ) ;
143147 fs . mkdirSync ( getDriveUploadingDir ( ) , { recursive : true } ) ;
144148 fs . mkdirSync ( getDriveUploadedDir ( ) , { recursive : true } ) ;
149+ fs . mkdirSync ( getDriveFailedDir ( ) , { recursive : true } ) ;
150+ }
151+
152+ // ============================================================================
153+ // STORAGE QUOTA (tier usage/limit)
154+ // ============================================================================
155+
156+ const STORAGE_USAGE_TTL_MS = 2 * 60 * 1000 ;
157+ let storageUsageCache = {
158+ data : null ,
159+ lastFetchMs : 0 ,
160+ lastError : null
161+ } ;
162+
163+ function formatBytes ( bytes ) {
164+ const n = Number ( bytes ) ;
165+ if ( ! Number . isFinite ( n ) || n < 0 ) return '0 B' ;
166+ const units = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' ] ;
167+ let v = n ;
168+ let i = 0 ;
169+ while ( v >= 1024 && i < units . length - 1 ) {
170+ v /= 1024 ;
171+ i ++ ;
172+ }
173+ const digits = i === 0 ? 0 : i === 1 ? 1 : 2 ;
174+ return `${ v . toFixed ( digits ) } ${ units [ i ] } ` ;
175+ }
176+
177+ function formatQuotaLine ( info ) {
178+ if ( ! info ) return 'Storage: (not loaded yet)' ;
179+ const tier = String ( info . tier || 'free' ) ;
180+ const usage = Number ( info . usage || 0 ) ;
181+ const limit = info . limit ;
182+ if ( limit === null || typeof limit === 'undefined' ) {
183+ return `Storage: ${ formatBytes ( usage ) } used (Tier: ${ tier } )` ;
184+ }
185+ const lim = Number ( limit ) ;
186+ if ( ! Number . isFinite ( lim ) || lim <= 0 ) {
187+ return `Storage: ${ formatBytes ( usage ) } used (Tier: ${ tier } )` ;
188+ }
189+ const remaining = Math . max ( 0 , lim - usage ) ;
190+ return `Storage: ${ formatBytes ( usage ) } / ${ formatBytes ( lim ) } (Remaining: ${ formatBytes ( remaining ) } )` ;
191+ }
192+
193+ async function fetchStorageUsageFromApi ( ) {
194+ const token = store . get ( 'authToken' , null ) ;
195+ if ( ! token ) throw new Error ( 'Not logged in' ) ;
196+
197+ const res = await axios . get ( `${ API_URL } /files/usage` , {
198+ headers : { Authorization : `Bearer ${ token } ` } ,
199+ timeout : 15000
200+ } ) ;
201+
202+ const usage = res ?. data ?. usage ;
203+ const limit = res ?. data ?. limit ;
204+ const tier = res ?. data ?. tier ;
205+
206+ if ( typeof usage === 'undefined' ) throw new Error ( 'Usage API returned no usage' ) ;
207+ return { usage, limit, tier } ;
208+ }
209+
210+ function buildStorageStatusText ( { info, error } ) {
211+ const now = new Date ( ) ;
212+ const lines = [ ] ;
213+ lines . push ( 'FileShot Drive — Storage Status' ) ;
214+ lines . push ( '' ) ;
215+ lines . push ( `Updated: ${ now . toLocaleString ( ) } ` ) ;
216+ lines . push ( '' ) ;
217+
218+ if ( error ) {
219+ lines . push ( 'Status: unavailable' ) ;
220+ lines . push ( `Error: ${ String ( error ) } ` ) ;
221+ } else if ( info ) {
222+ const tier = String ( info . tier || 'free' ) ;
223+ const usage = Number ( info . usage || 0 ) ;
224+ const limit = info . limit ;
225+ lines . push ( `Tier: ${ tier } ` ) ;
226+ if ( limit === null || typeof limit === 'undefined' ) {
227+ lines . push ( `Used: ${ formatBytes ( usage ) } ` ) ;
228+ lines . push ( 'Limit: Unlimited' ) ;
229+ } else {
230+ const lim = Number ( limit ) ;
231+ lines . push ( `Used: ${ formatBytes ( usage ) } ` ) ;
232+ lines . push ( `Limit: ${ formatBytes ( lim ) } ` ) ;
233+ lines . push ( `Remaining: ${ formatBytes ( Math . max ( 0 , lim - usage ) ) } ` ) ;
234+ }
235+ lines . push ( '' ) ;
236+ lines . push ( 'Tip: Upgrade or manage storage at https://fileshot.io/pricing.html' ) ;
237+ } else {
238+ lines . push ( 'Status: not loaded' ) ;
239+ lines . push ( 'Log in to see your plan storage limit.' ) ;
240+ }
241+
242+ lines . push ( '' ) ;
243+ lines . push ( 'Note: Windows Explorer drive “capacity” cannot be customized when using SUBST.' ) ;
244+ lines . push ( 'This file shows your actual FileShot account storage limit.' ) ;
245+ lines . push ( '' ) ;
246+ return lines . join ( os . EOL ) ;
247+ }
248+
249+ async function writeDriveStorageStatusFile ( { info, error } ) {
250+ if ( ! isDriveFeatureAvailable ( ) ) return ;
251+ try {
252+ await ensureDriveDirs ( ) ;
253+ const statusPath = path . join ( getDriveInboxDir ( ) , 'FILESHOT_STORAGE.txt' ) ;
254+ fs . writeFileSync ( statusPath , buildStorageStatusText ( { info, error } ) , 'utf8' ) ;
255+ } catch ( _ ) {
256+ // Best-effort.
257+ }
258+ }
259+
260+ async function refreshStorageUsage ( { force = false , reason = '' } = { } ) {
261+ const now = Date . now ( ) ;
262+ const isFresh = storageUsageCache . data && ( now - storageUsageCache . lastFetchMs ) < STORAGE_USAGE_TTL_MS ;
263+ if ( ! force && isFresh ) return storageUsageCache . data ;
264+
265+ try {
266+ const info = await fetchStorageUsageFromApi ( ) ;
267+ storageUsageCache = { data : info , lastFetchMs : now , lastError : null } ;
268+ await writeDriveStorageStatusFile ( { info, error : null } ) ;
269+ rebuildTrayMenu ( ) ;
270+ return info ;
271+ } catch ( e ) {
272+ const msg = e ?. response ?. data ?. error || e ?. message || String ( e ) ;
273+ storageUsageCache = { ...storageUsageCache , lastFetchMs : now , lastError : msg } ;
274+ await writeDriveStorageStatusFile ( { info : storageUsageCache . data , error : msg } ) ;
275+ // Don't spam rebuilds on failure; but tray may need to show error.
276+ rebuildTrayMenu ( ) ;
277+ return storageUsageCache . data ;
278+ } finally {
279+ if ( reason ) {
280+ // Lightweight debug breadcrumb.
281+ try { console . log ( '[StorageUsage] refreshed' , { reason, ok : ! storageUsageCache . lastError } ) ; } catch ( _ ) { }
282+ }
283+ }
145284}
146285
147286function sanitizeFileName ( name ) {
@@ -382,6 +521,62 @@ async function startDriveWatcher() {
382521 const baseName = path . basename ( originalPath ) ;
383522 const safeBase = sanitizeFileName ( baseName ) ;
384523
524+ // Best-effort: check quota before uploading so we can fail fast with a clear message.
525+ // If we can't fetch usage (offline/not logged in), we still attempt the upload and let the server enforce.
526+ let quotaInfo = null ;
527+ try {
528+ quotaInfo = await refreshStorageUsage ( { force : false , reason : 'drive:precheck' } ) ;
529+ } catch ( _ ) {
530+ quotaInfo = storageUsageCache . data ;
531+ }
532+
533+ if ( quotaInfo && quotaInfo . limit !== null && typeof quotaInfo . limit !== 'undefined' ) {
534+ const usage = Number ( quotaInfo . usage || 0 ) ;
535+ const limit = Number ( quotaInfo . limit ) ;
536+ const fileSize = Number ( st . size || 0 ) ;
537+ if ( Number . isFinite ( limit ) && limit > 0 && Number . isFinite ( usage ) && ( usage + fileSize ) > limit ) {
538+ const tier = String ( quotaInfo . tier || 'free' ) ;
539+ const msg = `Storage full (${ tier } ): ${ formatBytes ( usage ) } used of ${ formatBytes ( limit ) } . File is ${ formatBytes ( fileSize ) } .` ;
540+
541+ try {
542+ notifier . notify ( {
543+ title : 'FileShot Drive' ,
544+ message : msg ,
545+ icon : path . join ( __dirname , 'assets' , 'icon.png' ) ,
546+ sound : false
547+ } ) ;
548+ } catch ( _ ) { }
549+
550+ // Move into _failed so it's not retried endlessly.
551+ try {
552+ fs . mkdirSync ( getDriveFailedDir ( ) , { recursive : true } ) ;
553+ let dest = path . join ( getDriveFailedDir ( ) , safeBase ) ;
554+ if ( fs . existsSync ( dest ) ) {
555+ const ext = path . extname ( safeBase ) ;
556+ const stem = safeBase . slice ( 0 , safeBase . length - ext . length ) ;
557+ dest = path . join ( getDriveFailedDir ( ) , `${ stem } -${ Date . now ( ) } ${ ext } ` ) ;
558+ }
559+ fs . renameSync ( originalPath , dest ) ;
560+
561+ const errPath = `${ dest } .error.txt` ;
562+ const details = [
563+ 'FileShot Drive — Upload blocked (storage limit reached)' ,
564+ '' ,
565+ msg ,
566+ '' ,
567+ 'Manage your storage or upgrade:' ,
568+ 'https://fileshot.io/pricing.html' ,
569+ ''
570+ ] . join ( os . EOL ) ;
571+ fs . writeFileSync ( errPath , details , 'utf8' ) ;
572+ } catch ( _ ) {
573+ // If we can't move it, leave it where it is.
574+ }
575+
576+ continue ;
577+ }
578+ }
579+
385580 // Move into _uploading to prevent re-trigger and to visually separate state.
386581 const uploadingPath = path . join ( getDriveUploadingDir ( ) , safeBase ) ;
387582 let workPath = originalPath ;
@@ -576,6 +771,11 @@ async function enableFileShotDrive() {
576771 await startDriveWatcher ( ) ;
577772 rebuildTrayMenu ( ) ;
578773
774+ // Best-effort: create/update the storage status file immediately.
775+ refreshStorageUsage ( { force : true , reason : 'drive:enabled' } ) . catch ( ( ) => {
776+ writeDriveStorageStatusFile ( { info : storageUsageCache . data , error : storageUsageCache . lastError } ) . catch ( ( ) => { } ) ;
777+ } ) ;
778+
579779 try {
580780 shell . openPath ( getDriveRootPath ( letter ) ) ;
581781 } catch ( _ ) { }
@@ -1030,6 +1230,12 @@ function buildTrayMenuTemplate() {
10301230 const driveEnabled = Boolean ( store . get ( 'drive.enabled' , false ) ) ;
10311231 const driveLetter = getConfiguredDriveLetter ( ) ;
10321232
1233+ const storageInfo = storageUsageCache . data ;
1234+ const storageError = storageUsageCache . lastError ;
1235+ const storageLabel = storageError
1236+ ? `Storage: (error) ${ String ( storageError ) . slice ( 0 , 120 ) } `
1237+ : formatQuotaLine ( storageInfo ) ;
1238+
10331239 return [
10341240 {
10351241 label : 'Open FileShot' ,
@@ -1066,6 +1272,16 @@ function buildTrayMenuTemplate() {
10661272 }
10671273 }
10681274 } ,
1275+ {
1276+ label : storageLabel ,
1277+ enabled : false
1278+ } ,
1279+ {
1280+ label : 'Refresh Storage Info' ,
1281+ click : ( ) => {
1282+ refreshStorageUsage ( { force : true , reason : 'tray:refresh' } ) . catch ( ( ) => { } ) ;
1283+ }
1284+ } ,
10691285 {
10701286 label : 'Upload File' ,
10711287 click : ( ) => {
@@ -1108,6 +1324,24 @@ function buildTrayMenuTemplate() {
11081324 {
11091325 label : 'Note: drop files to auto-upload' ,
11101326 enabled : false
1327+ } ,
1328+ { type : 'separator' } ,
1329+ {
1330+ label : storageLabel ,
1331+ enabled : false
1332+ } ,
1333+ {
1334+ label : 'Open Storage Status File' ,
1335+ click : ( ) => {
1336+ ensureDriveDirs ( ) . catch ( ( ) => { } ) ;
1337+ shell . openPath ( path . join ( getDriveInboxDir ( ) , 'FILESHOT_STORAGE.txt' ) ) ;
1338+ }
1339+ } ,
1340+ {
1341+ label : 'Refresh Storage Info' ,
1342+ click : ( ) => {
1343+ refreshStorageUsage ( { force : true , reason : 'tray:drive-refresh' } ) . catch ( ( ) => { } ) ;
1344+ }
11111345 }
11121346 ]
11131347 } ,
@@ -1363,10 +1597,14 @@ ipcMain.handle('get-auth-token', () => {
13631597
13641598ipcMain . handle ( 'set-auth-token' , ( event , token ) => {
13651599 store . set ( 'authToken' , token ) ;
1600+ refreshStorageUsage ( { force : true , reason : 'auth:set-token' } ) . catch ( ( ) => { } ) ;
13661601} ) ;
13671602
13681603ipcMain . handle ( 'clear-auth-token' , ( ) => {
13691604 store . delete ( 'authToken' ) ;
1605+ storageUsageCache = { data : null , lastFetchMs : 0 , lastError : null } ;
1606+ writeDriveStorageStatusFile ( { info : null , error : null } ) . catch ( ( ) => { } ) ;
1607+ rebuildTrayMenu ( ) ;
13701608} ) ;
13711609
13721610ipcMain . handle ( 'add-recent-upload' , ( event , upload ) => {
@@ -2004,6 +2242,19 @@ app.whenReady().then(() => {
20042242 createWindow ( ) ;
20052243 createTray ( ) ;
20062244
2245+ // Keep storage info reasonably fresh (tray + FILESHOT_STORAGE.txt) while logged in.
2246+ if ( store . get ( 'authToken' , null ) ) {
2247+ refreshStorageUsage ( { force : true , reason : 'startup' } ) . catch ( ( ) => { } ) ;
2248+ } else {
2249+ // Ensure the status file exists with helpful guidance when the drive is enabled.
2250+ writeDriveStorageStatusFile ( { info : null , error : null } ) . catch ( ( ) => { } ) ;
2251+ }
2252+
2253+ setInterval ( ( ) => {
2254+ if ( ! store . get ( 'authToken' , null ) ) return ;
2255+ refreshStorageUsage ( { force : true , reason : 'poll' } ) . catch ( ( ) => { } ) ;
2256+ } , 5 * 60 * 1000 ) ;
2257+
20072258 // Windows: enable FileShot Drive by default on first run.
20082259 // (This was the core point of the v1.4.8 update; requiring a manual tray toggle is too easy to miss.)
20092260 if ( isDriveFeatureAvailable ( ) ) {
0 commit comments