fix context flags, ipc methods, push notification logic

This commit is contained in:
Tom (plebeius.eth)
2025-05-04 23:04:44 +02:00
parent 9013d6b5db
commit 1c16cc0551
20 changed files with 542 additions and 218 deletions

View File

@@ -1,18 +1,28 @@
// Import log.cjs
require('./log.cjs');
const { app, BrowserWindow, Menu, MenuItem, Tray, shell, dialog, nativeTheme, ipcMain, Notification } = require('electron');
// Import Electron components using CommonJS require
const { app, BrowserWindow, Menu, MenuItem, Tray, shell, dialog, nativeTheme, ipcMain, Notification, systemPreferences } = require('electron');
// Import Node.js built-ins using CommonJS
const path = require('path');
const fs = require('fs');
const { URL } = require('url');
const { URL, fileURLToPath } = require('url');
const util = require('util');
// Don't require EnvPaths directly, it will be dynamically imported later
// const EnvPaths = require('env-paths');
const FormData = require('form-data');
const fetch = require('node-fetch');
const contextMenu = require('electron-context-menu');
// Import local modules using CommonJS
require('./start-ipfs.cjs');
require('./start-plebbit-rpc.cjs');
// Load package.json using CommonJS
const packageJson = require('../package.json');
// Set ELECTRON_IS_DEV for other modules to use
process.env.ELECTRON_IS_DEV = app.isPackaged ? '0' : '1';
// Since we're in CommonJS, we can't use import.meta.url
@@ -76,6 +86,96 @@ startIpfs.onError = (error) => {
}
});
// Handle request for notification permission status
ipcMain.handle('get-notification-permission-status', async () => {
try {
// Check Notification API support
if (!Notification.isSupported()) {
console.log('[Electron Main] Notification API not supported.');
return 'not-supported';
}
// On macOS, prefer getNotificationSettings()
if (process.platform === 'darwin') {
if (typeof systemPreferences.getNotificationSettings === 'function') {
const settings = systemPreferences.getNotificationSettings();
const auth = settings.authorizationStatus; // 'authorized'|'denied'|'not-determined'
console.log('[Electron Main] macOS systemPreferences.getNotificationSettings() returned:', auth);
if (auth === 'denied') return 'denied';
if (auth === 'authorized') return 'granted';
return 'not-determined';
}
// Fallback to old API if present
if (typeof systemPreferences.getNotificationPermissionStatus === 'function') {
const status = systemPreferences.getNotificationPermissionStatus();
console.log('[Electron Main] macOS systemPreferences.getNotificationPermissionStatus() returned:', status);
return status;
}
console.warn('[Electron Main] No macOS notification permission API available; assuming granted.');
return 'granted';
}
// For Windows/Linux, assume granted if API supported
console.log('[Electron Main] Assuming notification permission granted on non-macOS platform.');
return 'granted';
} catch (error) {
console.error('[Electron Main] Error getting notification permission status:', error);
return 'unknown';
}
});
// Handle request for the current platform
ipcMain.handle('get-platform', async () => {
return process.platform; // Returns 'darwin', 'win32', 'linux', etc.
});
// Handle request to test notification permission (by sending one)
ipcMain.handle('test-notification-permission', async () => {
if (!Notification.isSupported()) {
console.warn('[Electron Main] Test notification requested, but not supported.');
return { success: false, reason: 'not-supported' };
}
try {
// Check status *before* trying to send, using the platform-aware logic
let status = 'unknown';
if (process.platform === 'darwin') {
// Explicitly check if the function exists before calling it
if (typeof systemPreferences.getNotificationPermissionStatus === 'function') {
status = systemPreferences.getNotificationPermissionStatus();
} else {
console.warn('[Electron Main Test] systemPreferences.getNotificationPermissionStatus is NOT a function. Falling back.');
status = Notification.isSupported() ? 'granted' : 'not-supported'; // Fallback for macOS
}
} else {
// Assume granted on other platforms if supported
status = Notification.isSupported() ? 'granted' : 'not-supported';
}
console.log('[Electron Main Test] Determined status before sending:', status);
if (status === 'denied') {
console.warn('[Electron Main Test] notification requested, but status is denied.');
return { success: false, reason: 'denied' };
}
if (status === 'not-supported') {
console.warn('[Electron Main Test] notification requested, but not supported.');
return { success: false, reason: 'not-supported' };
}
// Sending a notification is the standard way to trigger the 'not-determined' prompt on macOS
const testNotification = new Notification({
title: 'Seedit Test',
body: 'Testing if notifications are allowed.',
});
testNotification.show();
// We can't easily *confirm* it showed, but if no error and not denied/not-supported, assume success for now.
// The user will see (or not see) the notification.
console.log('[Electron Main Test] notification shown (or attempted). Status was:', status);
return { success: true };
} catch (error) {
console.error('[Electron Main Test] Error sending test notification:', error);
return { success: false, reason: 'error' };
}
});
// add right click menu
contextMenu({
// prepend custom buttons to top
@@ -119,7 +219,7 @@ startIpfs.onError = (error) => {
nodeIntegration: false,
contextIsolation: true,
devTools: !app.isPackaged, // Use app.isPackaged to determine if devTools should be enabled
preload: path.join(dirname, 'preload.cjs'),
preload: path.join(dirname, 'preload.cjs'), // Updated to use preload.cjs
// sandbox: false, // sandbox:false is generally discouraged for security unless strictly necessary. Re-evaluate if needed.
},
});
@@ -209,8 +309,8 @@ startIpfs.onError = (error) => {
const validatedUrl = new URL(originalUrl);
let serializedUrl = '';
// make an exception for ipfs stats
if (validatedUrl.toString() === 'http://localhost:50019/webui/') {
// make an exception for ipfs stats (allow proxy port 50019 in dev)
if (validatedUrl.toString() === 'http://localhost:50019/webui/') {
serializedUrl = validatedUrl.toString();
} else if (validatedUrl.protocol === 'https:') {
// open serialized url to prevent remote execution
@@ -239,8 +339,8 @@ startIpfs.onError = (error) => {
const validatedUrl = new URL(originalUrl);
let serializedUrl = '';
// make an exception for ipfs stats
if (validatedUrl.toString() === 'http://localhost:50019/webui/') {
// make an exception for ipfs stats (allow proxy port 50019 in dev)
if (validatedUrl.toString() === 'http://localhost:50019/webui/') {
serializedUrl = validatedUrl.toString();
} else if (validatedUrl.protocol === 'https:') {
// open serialized url to prevent remote execution
@@ -470,7 +570,3 @@ ipcMain.handle('plugin:file-uploader:pickMedia', async (event) => {
throw error;
}
});
ipcMain.handle('get-platform', () => {
return process.platform; // 'darwin', 'win32', 'linux', etc.
});

View File

@@ -2,9 +2,31 @@ const { contextBridge, ipcRenderer } = require('electron');
console.log('Preload script loaded.');
// Expose core IPC methods under 'electron'
contextBridge.exposeInMainWorld('electron', {
invoke: (channel, ...args) => {
const validChannels = [
'get-notification-permission-status',
'get-platform',
'test-notification-permission',
'show-notification'
];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
}
throw new Error(`Unauthorized IPC channel: ${channel}`);
},
sendNotification: (notification) => {
ipcRenderer.send('show-notification', notification);
}
});
// Expose higher-level API under 'electronApi'
contextBridge.exposeInMainWorld('electronApi', {
isElectron: true,
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
getNotificationStatus: () => ipcRenderer.invoke('get-notification-permission-status'),
getPlatform: () => ipcRenderer.invoke('get-platform'),
testNotification: () => ipcRenderer.invoke('test-notification-permission')
testNotification: () => ipcRenderer.invoke('test-notification-permission'),
showNotification: (notification) => ipcRenderer.send('show-notification', notification)
});

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, ipcMain, Notification, systemPreferences } from 'electron';
import * as path from 'path';
import { setupFileUploaderPlugin } from './plugins/file-uploader';
@@ -16,18 +16,20 @@ function createWindow() {
});
// Prevent file drops on the window
mainWindow.webContents.on('will-navigate', (e, url) => {
if (url !== mainWindow.webContents.getURL()) {
mainWindow!.webContents.on('will-navigate', (e, url) => {
if (url !== mainWindow!.webContents.getURL()) {
e.preventDefault();
}
});
// Prevent default file drop behavior
mainWindow.webContents.on('drop', (e) => {
// @ts-ignore: drop event not in WebContents type definitions
(mainWindow!.webContents as any).on('drop', (e: any) => {
e.preventDefault();
});
mainWindow.webContents.on('dragover', (e) => {
// @ts-ignore: dragover event not in WebContents type definitions
(mainWindow!.webContents as any).on('dragover', (e: any) => {
e.preventDefault();
});
@@ -44,6 +46,71 @@ function createWindow() {
}
app.whenReady().then(() => {
// --- notification IPC handlers ---
ipcMain.handle('get-notification-permission-status', async () => {
try {
// First check if the Notification API itself is supported
if (!Notification.isSupported()) {
console.log('[Electron Main] Notification API not supported.');
return 'not-supported';
}
// Platform-specific checks using Electron built-ins
if (process.platform === 'darwin') {
// macOS
if (typeof (systemPreferences as any).getNotificationSettings === 'function') {
// Use systemPreferences only if available (avoids electron internal warnings on older builds)
// @ts-ignore: getNotificationSettings may not be in TS definitions
const settings = (systemPreferences as any).getNotificationSettings();
const auth = settings.authorizationStatus as string; // 'authorized'|'denied'|'not-determined'
console.log('[Electron Main] macOS getNotificationSettings returned:', auth);
if (auth === 'denied') return 'denied';
if (auth === 'authorized') return 'granted';
return 'not-determined';
} else if (typeof (systemPreferences as any).getNotificationPermissionStatus === 'function') {
// Fallback to older API
// @ts-ignore
const status = (systemPreferences as any).getNotificationPermissionStatus();
console.log('[Electron Main] macOS getNotificationPermissionStatus returned:', status);
return status;
} else {
console.warn('[Electron Main] No macOS notification permission API available; assuming granted.');
return 'granted';
}
}
// Windows/Linux/Other: Assume granted if Notification API is supported
console.log('[Electron Main] Assuming notification permission granted on non-macOS platform.');
return 'granted';
} catch (error) {
console.error('[Electron Main] Error getting notification permission status:', error);
return 'unknown';
}
});
ipcMain.handle('get-platform', () => {
return process.platform as NodeJS.Platform;
});
// Changed from handle to on as it doesn't need to return a value
ipcMain.on('show-notification', (_evt, notificationData) => {
if (!Notification.isSupported()) {
console.log('Notifications not supported on this system.');
return; // Don't try to show if not supported
}
// Use the data passed from the renderer
const { title, body } = notificationData;
if (title && body) {
new Notification({ title, body }).show();
} else {
console.error('Invalid notification data received:', notificationData);
}
});
// Placeholder for test-notification if you implement it later
// ipcMain.handle('test-notification', async () => {
// // Implement test logic
// return { success: true };
// });
// ---------------------------------
createWindow();
app.on('activate', () => {

View File

@@ -5,10 +5,34 @@ import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electron', {
invoke: (channel: string, ...args: any[]) => {
// whitelist channels
const validChannels = ['plugin:file-uploader:pickAndUploadMedia', 'plugin:file-uploader:uploadMedia', 'plugin:file-uploader:pickMedia'];
const validChannels = [
'plugin:file-uploader:pickAndUploadMedia',
'plugin:file-uploader:uploadMedia',
'plugin:file-uploader:pickMedia',
'get-notification-permission-status', // actual channel
'get-platform',
'test-notification-permission', // correct test channel in main
'show-notification',
];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
}
throw new Error(`Unauthorized IPC channel: ${channel}`);
},
// Direct send for notifications
sendNotification: (notificationData: { title: string; body: string }) => {
ipcRenderer.send('show-notification', notificationData);
},
});
// Expose a dedicated "electronApi" for your UI code
contextBridge.exposeInMainWorld('electronApi', {
isElectron: true,
invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args),
getNotificationStatus: () => ipcRenderer.invoke('get-notification-permission-status'),
getPlatform: () => ipcRenderer.invoke('get-platform'),
testNotification: () => ipcRenderer.invoke('test-notification'), // Note: show-notification uses the other API
showNotification: (notificationData: { title: string; body: string }) => {
ipcRenderer.send('show-notification', notificationData);
},
});

View File

@@ -5,96 +5,115 @@ const ps = require('node:process');
const tcpPortUsed = require('tcp-port-used');
const proxyServer = require('./proxy-server.cjs');
// Use app.isPackaged defined in main.js via process.env
// Instead of using electron-is-dev, use app.isPackaged
// (we already made this change in main.js)
const isDev = process.env.ELECTRON_IS_DEV === '1';
// CommonJS equivalent of __dirname
// Use __dirname directly instead of fileURLToPath(import.meta.url)
const dirname = __dirname;
// Flag to ensure proxy is started only once
let proxyStarted = false;
// Start a debugging proxy for the IPFS API only in development mode.
// This allows connecting dev tools (like the IPFS Web UI) to the standard
// port 50019 while the actual daemon runs on 50029.
// Function to start the proxy if in dev mode and not already started
function startDevProxyOnce() {
if (isDev && !proxyStarted) {
const proxyPort = 50019;
const targetPort = 50029;
const proxyPort = 50019;
const targetPort = 50029; // Actual IPFS API port in dev
try {
console.log(`Attempting to start development proxy server on port ${proxyPort}...`);
proxyServer.start({ proxyPort, targetPort });
proxyStarted = true;
proxyStarted = true; // Set flag only after successful start
console.log(`Development proxy server started successfully on port ${proxyPort}, forwarding to ${targetPort}.`);
} catch (e) {
// Log the error but don't necessarily stop everything,
// as the proxy is mainly for debugging.
console.error(`Failed to start development proxy server on port ${proxyPort}:`, e);
// Consider if proxyStarted should be set to true even on failure to prevent retries
// proxyStarted = true;
}
}
}
// Handles IPFS setup: finding binary, initializing repo, configuring ports, and starting daemon.
// Make the main logic async to handle dynamic import
async function initializeIpfs() {
// Dynamically import env-paths
const EnvPaths = (await import('env-paths')).default;
const envPaths = EnvPaths('plebbit', { suffix: false });
const ipfsFileName = process.platform == 'win32' ? 'ipfs.exe' : 'ipfs';
let ipfsPath = path.join(process.resourcesPath, 'bin', ipfsFileName); // Packaged app path
let ipfsDataPath = path.join(envPaths.data, 'ipfs'); // Standard data path
let ipfsPath = path.join(process.resourcesPath, 'bin', ipfsFileName);
let ipfsDataPath = path.join(envPaths.data, 'ipfs');
// Override paths for development mode
// test launching the ipfs binary in dev mode
// they must be downloaded first using `yarn electron:build`
if (isDev) {
let binFolderName = 'win';
if (process.platform === 'linux') binFolderName = 'linux';
if (process.platform === 'darwin') binFolderName = 'mac';
if (process.platform === 'linux') {
binFolderName = 'linux';
}
if (process.platform === 'darwin') {
binFolderName = 'mac';
}
ipfsPath = path.join(dirname, '..', 'bin', binFolderName, ipfsFileName);
ipfsDataPath = path.join(dirname, '..', '.plebbit', 'ipfs');
}
if (!fs.existsSync(ipfsPath)) {
throw Error(`ipfs binary '${ipfsPath}' doesn't exist. Run 'yarn electron:build' or 'yarn electron:before:download-ipfs' first?`);
throw Error(`ipfs binary '${ipfsPath}' doesn't exist`);
}
console.log({ ipfsPath, ipfsDataPath });
fs.ensureDirSync(ipfsDataPath);
const env = { IPFS_PATH: ipfsDataPath };
// init ipfs client on first launch
try {
await spawnAsync(ipfsPath, ['init'], { env, hideWindows: true });
} catch (e) {}
// Attempt to initialize the IPFS repo; ignore errors if already initialized.
try { await spawnAsync(ipfsPath, ['init'], { env, hideWindows: true }); } catch (e) {}
// make sure repo is migrated
try {
await spawnAsync(ipfsPath, ['repo', 'migrate'], { env, hideWindows: true });
} catch (e) {}
// Ensure the repo is migrated to the latest version.
try { await spawnAsync(ipfsPath, ['repo', 'migrate'], { env, hideWindows: true }); } catch (e) {}
// dont use 8080 port because it's too common
await spawnAsync(ipfsPath, ['config', '--json', 'Addresses.Gateway', '"/ip4/127.0.0.1/tcp/6473"'], {
env,
hideWindows: true,
});
// Configure IPFS Gateway port (avoiding common port 8080).
await spawnAsync(ipfsPath, ['config', '--json', 'Addresses.Gateway', '"/ip4/127.0.0.1/tcp/6473"'], { env, hideWindows: true });
// Configure IPFS API port. Use a different port in dev (50029) vs prod (50019).
// use different port with proxy for debugging during env
let apiAddress = '/ip4/127.0.0.1/tcp/50019';
if (isDev) {
apiAddress = apiAddress.replace('50019', '50029');
// Do NOT start proxy server here
// proxyServer.start({ proxyPort: 50039, targetPort: 50029 });
}
await spawnAsync(ipfsPath, ['config', 'Addresses.API', apiAddress], { env, hideWindows: true });
await startIpfsDaemon(ipfsPath, env);
// Attempt to start the dev proxy *after* trying to start the daemon (increases chance target port is ready).
startDevProxyOnce();
// Attempt to start proxy AFTER daemon reports ready (or at least after start attempt)
// This increases chances the target port 50029 is actually listening.
startDevProxyOnce();
}
// Wrapper around child_process.spawn for better logging and Promise interface.
// use this custom function instead of spawnSync for better logging
// also spawnSync might have been causing crash on start on windows
const spawnAsync = (...args) =>
new Promise((resolve, reject) => {
const spawnedProcess = spawn(...args);
spawnedProcess.on('exit', (exitCode, signal) => {
const spawedProcess = spawn(...args);
spawedProcess.on('exit', (exitCode, signal) => {
if (exitCode === 0) resolve();
else reject(Error(`spawnAsync process '${spawnedProcess.pid}' exited with code '${exitCode}' signal '${signal}'`));
else reject(Error(`spawnAsync process '${spawedProcess.pid}' exited with code '${exitCode}' signal '${signal}'`));
});
spawnedProcess.stderr.on('data', (data) => console.error(data.toString()));
spawnedProcess.stdin.on('data', (data) => console.log(data.toString()));
spawnedProcess.stdout.on('data', (data) => console.log(data.toString()));
spawnedProcess.on('error', (data) => console.error(data.toString()));
spawedProcess.stderr.on('data', (data) => console.error(data.toString()));
spawedProcess.stdin.on('data', (data) => console.log(data.toString()));
spawedProcess.stdout.on('data', (data) => console.log(data.toString()));
spawedProcess.on('error', (data) => console.error(data.toString()));
});
// Starts the IPFS daemon process and resolves when it's ready.
const startIpfsDaemon = (ipfsPath, env) =>
new Promise((resolve, reject) => {
const ipfsProcess = spawn(ipfsPath, ['daemon', '--migrate', '--enable-pubsub-experiment', '--enable-namesys-pubsub'], { env, hideWindows: true });
@@ -117,15 +136,14 @@ const startIpfsDaemon = (ipfsPath, env) =>
console.error(`ipfs process with pid ${ipfsProcess.pid} exited`);
reject(Error(lastError));
});
// Ensure IPFS daemon is killed when the Electron app exits.
// Restore the exit handler to cleanly kill the daemon on Electron exit
process.on('exit', () => {
try {
console.log(`Attempting to kill IPFS daemon (pid: ${ipfsProcess.pid}) on Electron exit...`);
ps.kill(ipfsProcess.pid);
console.log(`Successfully sent kill signal to IPFS daemon (pid: ${ipfsProcess.pid}).`);
} catch (e) {
// Ignore error if process doesn't exist (ESRCH).
// Ignore ESRCH errors (process already gone)
if (e.code !== 'ESRCH') {
console.warn(`Warn: Failed to kill IPFS daemon (pid: ${ipfsProcess.pid}) on exit:`, e.message);
}
@@ -133,39 +151,45 @@ const startIpfsDaemon = (ipfsPath, env) =>
});
});
// Export object for other modules to attach an onError handler.
// Export object for error handling
const DefaultExport = {};
// Checks periodically if IPFS daemon is running (by checking its API port)
// and starts it via initializeIpfs if not.
// Auto-restart logic now calls the async initializeIpfs
const startIpfsAutoRestart = async () => {
let pendingStart = false;
const start = async () => {
if (pendingStart) return;
if (pendingStart) {
return;
}
pendingStart = true;
try {
const apiPort = isDev ? 50029 : 50019; // Check the *actual* daemon port
const apiPort = isDev ? 50029 : 50019;
const started = await tcpPortUsed.check(apiPort, '127.0.0.1');
if (!started) {
console.log(`IPFS API port ${apiPort} not detected. Initializing IPFS...`);
await initializeIpfs();
await initializeIpfs(); // Initialize IPFS daemon (will also try to start proxy via startDevProxyOnce)
} else {
console.log(`IPFS API port ${apiPort} already in use. Assuming IPFS is running.`);
// Ensure dev proxy is started if IPFS was already running.
// Ensure proxy is started even if IPFS was already running from a previous session
startDevProxyOnce();
}
} catch (e) {
console.log('failed starting ipfs', e);
DefaultExport.onError?.(e);
try {
// try to run exported onError callback, can be undefined
DefaultExport.onError(e)?.catch?.((console.log));
} catch (e) {}
}
pendingStart = false;
};
// Try starting dev proxy immediately in case IPFS is already running.
// Try starting the proxy once immediately at the beginning,
// in case IPFS is already running and the check above runs later.
startDevProxyOnce();
start(); // Initial check
setInterval(start, 5000); // Periodic check
// Start check/initialization loop
start();
setInterval(start, 5000); // Check every 5 seconds
};
startIpfsAutoRestart();

View File

@@ -5,49 +5,53 @@ import useContentOptionsStore from '../../stores/use-content-options-store';
import { showLocalNotification } from '../../lib/push';
/**
* Listens for new notifications from useNotifications and triggers platform-specific
* local notification display if enabled in settings.
* Does not render any UI itself.
* This component handles listening for new notifications from the useNotifications hook
* and triggering the platform-specific local notification display.
* It doesn't render anything itself, but runs its logic via useEffect.
*/
export const NotificationHandler = () => {
const { enableLocalNotifications } = useContentOptionsStore();
const { notifications } = useNotifications();
const { notifications } = useNotifications(); // Use real hook
const location = useLocation();
const previousNotificationsRef = useRef(notifications);
const previousNotificationsRef = useRef(notifications); // Use ref based on real hook
useEffect(() => {
// Only proceed if notifications are enabled in settings
if (!enableLocalNotifications) {
return;
}
// Check for new notifications compared to the previous state
const previousCids = new Set(previousNotificationsRef.current?.map((n) => n.cid) || []);
const newNotifications = notifications?.filter((n) => !previousCids.has(n.cid)) || [];
newNotifications.forEach((notification) => {
// Don't notify if the user is already viewing the inbox
// Basic check: don't notify if the user is already on the inbox page
if (location.pathname.startsWith('/inbox')) {
console.log('[NotificationHandler] Skipping notification, user is in inbox.', notification.cid);
return;
}
// TODO: Enhance title/body/URL based on notification type (reply vs mention etc.)
// Construct the notification payload
// TODO: Enhance title/body/URL based on notification type (reply, mention, etc.)
const payload = {
title: 'New Notification', // Generic title for now
body: notification.text || 'You have a new notification.', // Fallback body
url: `/p/${notification.subplebbitAddress}/c/${notification.cid}`, // Link to comment
body: notification.text || 'You have a new notification.', // Use comment text or generic body
url: `/p/${notification.subplebbitAddress}/c/${notification.cid}`, // Link directly to the comment
};
console.log('[NotificationHandler] Triggering notification:', payload);
// Show the notification
showLocalNotification(payload);
});
// Update the reference for the next comparison
previousNotificationsRef.current = notifications;
// Dependencies ensure this runs when relevant state changes,
// including location to re-evaluate the inbox check.
// Rerun when notifications, setting, or location changes
}, [notifications, enableLocalNotifications, location.pathname]);
return null;
return null; // This component doesn't render anything
};
// Optional: Default export if preferred

View File

@@ -31,7 +31,7 @@ import Version from '../version';
import { FAQ } from '../../views/about/about';
import { createCommunitySubtitles } from '../../constants/create-community-subtitles';
const isElectron = window.isElectron === true;
const isElectron = window.electronApi?.isElectron === true;
const RulesList = ({ rules }: { rules: string[] }) => {
const { t } = useTranslation();

3
src/globals.d.ts vendored
View File

@@ -9,7 +9,8 @@ declare global {
interface Window {
electronApi?: {
isElectron: boolean;
getNotificationStatus: () => Promise<'granted' | 'denied' | 'not-determined'>;
invoke: (channel: string, ...args: any[]) => Promise<any>;
getNotificationStatus: () => Promise<'granted' | 'denied' | 'not-determined' | 'not-supported'>;
getPlatform: () => Promise<NodeJS.Platform>;
testNotification: () => Promise<{ success: boolean; reason?: string }>;
};

View File

@@ -1,44 +1,68 @@
import type { LocalNotification } from './common';
// Define the dedicated Seedit IPC API exposed via preload
// Keep the existing electron definition if other parts of the app use it
declare global {
interface Window {
seeditIpc?: {
send: (channel: string, ...args: any[]) => void;
// Add invoke/on if needed for seedit-specific IPC
};
// Keep existing electron definition if other parts of the app use it
electron?: {
invoke: (channel: string, ...args: any[]) => Promise<any>;
sendNotification?: (notification: Omit<LocalNotification, 'id'>) => void;
};
// The new electronApi for cleaner separation
electronApi?: {
// Allow direct invoke as exposed in preload
invoke: (channel: string, ...args: any[]) => Promise<any>;
isElectron: boolean;
getNotificationStatus: () => Promise<'granted' | 'denied' | 'not-determined' | 'not-supported'>;
getPlatform: () => Promise<NodeJS.Platform>;
testNotification: () => Promise<{ success: boolean; reason?: string }>;
showNotification: (notification: Omit<LocalNotification, 'id'>) => void;
};
// Remove seeditIpc if no longer needed elsewhere
// seeditIpc?: {
// send: (channel: string, ...args: any[]) => void;
// };
}
}
/**
* Requests permission to display notifications in Electron.
* On Electron, permission is implicitly granted, but we check if the API exists.
* Uses the new electronApi for clarity.
*/
export async function requestElectronNotificationPermission(): Promise<boolean> {
// Check if the preload script successfully exposed the Seedit IPC API
const supported = typeof window.seeditIpc?.send === 'function';
// Just check if the API exists via preload.
const supported = typeof window.electronApi?.getNotificationStatus === 'function';
if (!supported) {
console.warn('Seedit Electron IPC for notifications not available.');
console.warn('Electron API for notifications not available via preload.');
}
// We don't need to ask the user for permission like in the browser
// No explicit permission needed from user in Electron, just API availability.
return supported;
}
/**
* Shows a local notification via Electron IPC using the dedicated Seedit API.
* Shows a local notification via Electron IPC using the exposed 'electron.invoke'.
*/
export async function showElectronLocalNotification(notification: Omit<LocalNotification, 'id'>): Promise<void> {
if (window.seeditIpc?.send) {
try {
window.seeditIpc.send('show-notification', notification);
} catch (error) {
console.error('Error sending notification via Seedit IPC:', error);
}
} else {
console.warn('Seedit Electron IPC send function not available.');
// Try the high-level API
if (typeof window.electronApi?.showNotification === 'function') {
// @ts-ignore
window.electronApi.showNotification(notification);
return;
}
// Next, try the low-level send
if (typeof window.electron?.sendNotification === 'function') {
// @ts-ignore
window.electron.sendNotification(notification);
return;
}
// Finally, fallback to invoke
if (typeof window.electron?.invoke === 'function') {
try {
await window.electron.invoke('show-notification', notification);
} catch (err) {
console.error('Error sending notification via Electron invoke:', err);
}
return;
}
console.error('No available IPC method to send Electron notification.');
}

View File

@@ -4,11 +4,15 @@ import { requestWebNotificationPermission, showWebLocalNotification } from './we
import { requestNativeNotificationPermission, showNativeLocalNotification, initializeNativeNotificationListeners } from './native';
import { requestElectronNotificationPermission, showElectronLocalNotification } from './electron';
// --- Platform Detection ---
// --- Platform Detection Functions ---
function checkIsElectron(): boolean {
// Check window property dynamically
return window.electronApi?.isElectron === true;
}
const isElectron = typeof window.seeditIpc?.send === 'function';
const isNativePlatform = Capacitor.isNativePlatform();
const isWebPlatform = !isNativePlatform && !isElectron;
function checkIsNativePlatform(): boolean {
return Capacitor.isNativePlatform();
}
let notificationIdCounter = Date.now(); // Simple counter for unique IDs
@@ -19,10 +23,12 @@ let notificationIdCounter = Date.now(); // Simple counter for unique IDs
* Should be called once when the app starts.
*/
export function initializeNotificationSystem(): void {
if (isNativePlatform) {
const isNative = checkIsNativePlatform();
const isElectron = checkIsElectron();
if (isNative) {
initializeNativeNotificationListeners();
}
console.log('Notification system initialized for platform:', isNativePlatform ? 'Native' : isElectron ? 'Electron' : 'Web');
console.log('Notification system initialized for platform:', isNative ? 'Native' : isElectron ? 'Electron' : 'Web');
}
// --- Permissions ---
@@ -32,14 +38,17 @@ export function initializeNotificationSystem(): void {
* Must be called from a user interaction context (e.g., button click).
*/
export async function requestNotificationPermission(): Promise<boolean> {
if (isNativePlatform) {
const isNative = checkIsNativePlatform();
const isElectron = checkIsElectron();
if (isNative) {
return requestNativeNotificationPermission();
}
if (isElectron) {
// Permission is implicit, just check if IPC is working
return requestElectronNotificationPermission();
}
if (isWebPlatform) {
// Fallback to Web Platform
if (!isNative && !isElectron) {
return requestWebNotificationPermission();
}
console.warn('Notification permission request: Unknown platform.');
@@ -52,21 +61,22 @@ export async function requestNotificationPermission(): Promise<boolean> {
* Shows a local notification using the appropriate platform API.
*/
export async function showLocalNotification(notificationData: Omit<LocalNotification, 'id'>): Promise<void> {
// Generate a unique ID required by Capacitor
const id = notificationIdCounter++;
const platformNotification = { ...notificationData, id };
console.log('Attempting to show notification:', platformNotification);
if (isNativePlatform) {
const isNative = checkIsNativePlatform();
const isElectron = checkIsElectron();
if (isNative) {
return showNativeLocalNotification(platformNotification);
}
if (isElectron) {
// Electron doesn't need the ID, but doesn't hurt to pass it
return showElectronLocalNotification(platformNotification);
}
if (isWebPlatform) {
// Service Worker doesn't need the ID, but doesn't hurt to pass it
// Fallback to Web Platform
if (!isNative && !isElectron) {
return showWebLocalNotification(platformNotification);
}

View File

@@ -41,12 +41,3 @@ const FileUploader = Capacitor.isNativePlatform()
};
export default FileUploader;
// Add TypeScript type declaration for Electron
declare global {
interface Window {
electron?: {
invoke(channel: string, ...args: any[]): Promise<any>;
};
}
}

View File

@@ -14,29 +14,32 @@ precacheAndRoute(self.__WB_MANIFEST);
self.skipWaiting();
clientsClaim();
// Interface for the notification data expected from the main app
interface NotificationData {
title: string;
body: string;
icon?: string;
url?: string;
url?: string; // URL to open on click
}
// Listen for messages from the client (main app)
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
const data: NotificationData = event.data.payload;
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon || '/android-chrome-192x192.png',
data: { url: data.url },
tag: data.body + '_' + Date.now(),
icon: data.icon || '/android-chrome-192x192.png', // Default icon
data: { url: data.url }, // Pass URL to click handler
tag: data.body + '_' + Date.now(), // Make the tag unique by adding a timestamp
}),
);
}
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.notification.close(); // Close the notification
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {

View File

@@ -50,10 +50,10 @@ export const FAQ = () => {
</h3>
<p>
Welcome! Your account <Link to='/profile'>u/{account?.author?.shortAddress}</Link> was created automatically and it's stored locally (
{window.isElectron ? 'on this desktop app' : isAndroid ? 'on this mobile app' : `on ${window.location.hostname}`}, not on a server). You can back up your
account in the <Link to='/settings#exportAccount'>preferences</Link>. There are no global rules or admins on Seedit, each community has its own rules and
moderators, so please be sure to read the rules of the community you are joining. You can connect peer-to-peer to any community by using the search bar, or you
can check out the <Link to='/communities/vote'>default community list</Link>.
{window.electronApi?.isElectron ? 'on this desktop app' : isAndroid ? 'on this mobile app' : `on ${window.location.hostname}`}, not on a server). You can back
up your account in the <Link to='/settings#exportAccount'>preferences</Link>. There are no global rules or admins on Seedit, each community has its own rules
and moderators, so please be sure to read the rules of the community you are joining. You can connect peer-to-peer to any community by using the search bar, or
you can check out the <Link to='/communities/vote'>default community list</Link>.
</p>
<hr />
<h3 id='whatIsSeedit'>What is Seedit and how does it work?</h3>

View File

@@ -8,10 +8,48 @@ const MediaOptions = () => {
return (
<div className={styles.contentOptions}>
<div className={styles.contentOptionTitle}>thumbnails</div>
<div>
<label>
<input type='radio' />
Show thumbnails next to links
</label>
</div>
<div>
<label>
<input type='radio' />
Don't show thumbnails next to links
</label>
</div>
<br />
<div className={styles.contentOptionTitle}>media previews</div>
<div>
<label>
<input type='radio' />
Auto-expand media previews
</label>
</div>
<div>
<label>
<input type='radio' />
Don't auto-expand media previews on comments pages
</label>
</div>
<br />
<div className={styles.contentOptionTitle}>Video File Player</div>
<div>
<label>
<input type='checkbox' />
Autoplay video files on the comments page
</label>
</div>
<br />
<div className={styles.contentOptionTitle}>{t('nsfw_content')}</div>
<div>
<input type='checkbox' id='blurNsfwThumbnails' checked={blurNsfwThumbnails} onChange={(e) => setBlurNsfwThumbnails(e.target.checked)} />
<label htmlFor='blurNsfwThumbnails'>{t('blur_media')}</label>
<label>
<input type='checkbox' checked={blurNsfwThumbnails} onChange={(e) => setBlurNsfwThumbnails(e.target.checked)} />
{t('blur_media')}
</label>
</div>
</div>
);
@@ -34,55 +72,80 @@ const CommunitiesOptions = () => {
return (
<div className={styles.contentOptions}>
<div className={styles.contentOptionTitle}>{t('default_communities')}</div>
<div>
<input
type='checkbox'
id='hideAdultCommunities'
ref={(el) => {
if (el) {
const allChecked = !hideAdultCommunities && !hideGoreCommunities && !hideAntiCommunities && !hideVulgarCommunities;
const someChecked = !hideAdultCommunities || !hideGoreCommunities || !hideAntiCommunities || !hideVulgarCommunities;
const allHidden = hideAdultCommunities && hideGoreCommunities && hideAntiCommunities && hideVulgarCommunities;
const someHidden = hideAdultCommunities || hideGoreCommunities || hideAntiCommunities || hideVulgarCommunities;
el.checked = allChecked;
el.indeterminate = someChecked && !allChecked;
el.checked = allHidden;
el.indeterminate = someHidden && !allHidden;
}
}}
onChange={(e) => {
const newValue = e.target.checked;
setHideAdultCommunities(!newValue);
setHideGoreCommunities(!newValue);
setHideAntiCommunities(!newValue);
setHideVulgarCommunities(!newValue);
setHideAdultCommunities(newValue);
setHideGoreCommunities(newValue);
setHideAntiCommunities(newValue);
setHideVulgarCommunities(newValue);
}}
/>
<label htmlFor='hideAdultCommunities'>{t('hide_communities_tagged_as_nsfw')}</label>
</div>
<div className={styles.nsfwTag}>
<label>
<input type='checkbox' checked={!hideAdultCommunities} onChange={(e) => setHideAdultCommunities(!e.target.checked)} />
<input
type='checkbox'
checked={hideAdultCommunities}
onChange={(e) => {
setHideAdultCommunities(e.target.checked);
}}
/>
{t('tagged_as_adult')}
</label>
</div>
<div className={styles.nsfwTag}>
<label>
<input type='checkbox' checked={!hideGoreCommunities} onChange={(e) => setHideGoreCommunities(!e.target.checked)} />
<input
type='checkbox'
checked={hideGoreCommunities}
onChange={(e) => {
setHideGoreCommunities(e.target.checked);
}}
/>
{t('tagged_as_gore')}
</label>
</div>
<div className={styles.nsfwTag}>
<label>
<input type='checkbox' checked={!hideAntiCommunities} onChange={(e) => setHideAntiCommunities(!e.target.checked)} />
<input
type='checkbox'
checked={hideAntiCommunities}
onChange={(e) => {
setHideAntiCommunities(e.target.checked);
}}
/>
{t('tagged_as_anti')}
</label>
</div>
<div className={styles.nsfwTag}>
<label>
<input type='checkbox' checked={!hideVulgarCommunities} onChange={(e) => setHideVulgarCommunities(!e.target.checked)} />
<input
type='checkbox'
checked={hideVulgarCommunities}
onChange={(e) => {
setHideVulgarCommunities(e.target.checked);
}}
/>
{t('tagged_as_vulgar')}
</label>
</div>
<br />
<div className={styles.contentOptionTitle}>{t('communities')}</div>
<div className={styles.contentOptionTitle}>topbar</div>
<label>
<input type='checkbox' checked={hideDefaultCommunities} onChange={(e) => setHideDefaultCommunities(e.target.checked)} />
{t('hide_default_communities_from_topbar')}

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect } from 'react';
import { useState } from 'react';
import useContentOptionsStore from '../../../stores/use-content-options-store';
import { useCallback, useEffect, useState } from 'react';
import { requestNotificationPermission } from '../../../lib/push';
import styles from './notifications-settings.module.css';
import useContentOptionsStore from '../../../stores/use-content-options-store';
const NotificationsSettings = () => {
const { enableLocalNotifications, setEnableLocalNotifications } = useContentOptionsStore();
@@ -10,88 +9,75 @@ const NotificationsSettings = () => {
const [platform, setPlatform] = useState<NodeJS.Platform | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showDeniedMessage, setShowDeniedMessage] = useState(false);
const [showSuccessMessage, setShowSuccessMessage] = useState(false);
// Function to check permission via API, memoized with useCallback
const checkPermissionStatus = useCallback(async () => {
// if (!window.electronApi?.getNotificationStatus) return; // Commented out
console.warn('[NotificationsSettings] checkPermissionStatus called, but electronApi.getNotificationStatus is disabled.');
if (!window.electronApi?.getNotificationStatus) return;
console.log('[Electron Native] Checking OS notification permission status...');
try {
// This call now correctly handles 'not-supported' internally in main.cjs
// const nativeStatus = await window.electronApi.getNotificationStatus(); // Commented out
const nativeStatus = 'unknown' as any; // Mock status, cast to any to bypass linter
const nativeStatus = await window.electronApi.getNotificationStatus();
console.log('[Electron Native] OS permission status from native API:', nativeStatus);
setPermissionStatus(nativeStatus); // Directly set the status received
if (nativeStatus === 'granted') {
// On macOS, even if the API returns 'granted', we should do a real test
// to confirm notifications are actually working, unless it's already tested ok
// if (platform === 'darwin' && !testResult?.success) { // Logic using testNotificationPermission commented out
// testNotificationPermission(); // Test to ensure it *really* works
// } else
if (!enableLocalNotifications) {
// Update store only if needed
setEnableLocalNotifications(true);
}
} else if (nativeStatus === 'denied') {
if (nativeStatus === 'denied') {
if (enableLocalNotifications) {
setEnableLocalNotifications(false);
}
setShowDeniedMessage(true);
setTimeout(() => setShowDeniedMessage(false), 5000);
} else if (nativeStatus === 'not-determined') {
// If undetermined, try a direct test which might trigger the prompt
// testNotificationPermission(); // Logic using testNotificationPermission commented out
console.warn('[NotificationsSettings] Permission status is not-determined, cannot test.');
} else if (nativeStatus === 'not-supported') {
// If not supported, ensure checkbox is off
if (enableLocalNotifications) {
setEnableLocalNotifications(false);
}
} else if (nativeStatus === 'unknown') {
// Handle the mocked 'unknown' state
console.warn('[NotificationsSettings] Permission status is unknown (Electron API disabled).');
// Optionally disable the checkbox or show a specific message
if (enableLocalNotifications) {
// Maybe keep it enabled but show a warning?
// Or disable it:
// setEnableLocalNotifications(false);
}
}
} catch (err) {
console.error('[Electron Native] Error checking notification permissions:', err);
// On error, fall back to the test notification approach as a last resort
// testNotificationPermission(); // Logic using testNotificationPermission commented out
setPermissionStatus('unknown'); // Set status to unknown on error too
setPermissionStatus('unknown');
}
}, [enableLocalNotifications, setEnableLocalNotifications /*, testNotificationPermission */]); // Dependencies for checkPermissionStatus, commented out testNotificationPermission
}, [setEnableLocalNotifications]);
// Run the direct test on mount
// Run the check on mount
useEffect(() => {
if (window.electronApi) {
// Get platform first
if (window.electronApi.getPlatform) {
window.electronApi.getPlatform().then(setPlatform).catch(console.error);
}
// Then check notification permission status
checkPermissionStatus();
}
}, [checkPermissionStatus]); // Now depends on the memoized function
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Keep dependencies empty - runs only once
const handleCheckboxChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const isEnabled = event.target.checked;
setIsLoading(true);
setShowSuccessMessage(false);
try {
if (isEnabled) {
// If in Electron, do a direct test
if (window.electronApi) {
// await testNotificationPermission(); // Commented out
console.warn('[NotificationsSettings] testNotificationPermission call skipped in handleCheckboxChange.');
setPermissionStatus('unknown'); // Set to unknown as we can't test
// If in Electron, check status first
if (window.electronApi?.getNotificationStatus) {
const currentStatus = await window.electronApi.getNotificationStatus();
setPermissionStatus(currentStatus);
if (currentStatus === 'granted') {
setEnableLocalNotifications(true);
setShowSuccessMessage(true);
setTimeout(() => setShowSuccessMessage(false), 5000);
} else if (currentStatus === 'denied') {
setEnableLocalNotifications(false); // Ensure it's off if denied
setShowDeniedMessage(true);
setTimeout(() => setShowDeniedMessage(false), 5000);
} else if (currentStatus === 'not-determined') {
setEnableLocalNotifications(false); // Keep it off until granted
console.warn('[NotificationsSettings] Permission not determined. User must grant via OS prompt.');
alert('Notification permission needed. The app will ask when it first tries to notify you, or check System Settings.');
} else if (currentStatus === 'not-supported') {
setEnableLocalNotifications(false); // Keep it off
}
} else {
// Use the web browser API for non-Electron
setPermissionStatus('requesting...');
@@ -99,6 +85,8 @@ const NotificationsSettings = () => {
if (granted) {
setEnableLocalNotifications(true);
setPermissionStatus('granted');
setShowSuccessMessage(true);
setTimeout(() => setShowSuccessMessage(false), 5000);
} else {
setEnableLocalNotifications(false);
setPermissionStatus('denied');
@@ -108,7 +96,6 @@ const NotificationsSettings = () => {
}
} else {
setEnableLocalNotifications(false);
// Don't change permissionStatus when disabling
}
} finally {
setIsLoading(false);
@@ -117,11 +104,33 @@ const NotificationsSettings = () => {
// Function to manually test a notification
const showTestNotification = () => {
// If we're on Electron, we should verify permission status first
if (window.electronApi) {
// testNotificationPermission(); // Commented out
console.warn('[NotificationsSettings] testNotificationPermission call skipped in showTestNotification.');
alert('Cannot test Electron notifications currently.');
// If we're on Electron, check status before attempting to show
if (window.electronApi?.getNotificationStatus) {
window.electronApi
.getNotificationStatus()
.then((status) => {
if (status === 'granted') {
// Use the showElectronLocalNotification via the main push index
import('../../../lib/push').then(({ showLocalNotification }) => {
showLocalNotification({
title: 'Electron Test Notification!',
body: 'If you see this, permissions are working!',
});
});
} else if (status === 'denied') {
alert('Notifications are denied in System Settings.');
setShowDeniedMessage(true);
setTimeout(() => setShowDeniedMessage(false), 5000);
} else if (status === 'not-determined') {
alert('Permission not yet granted. The app will ask when it first tries to notify you (or test again).');
} else {
alert('Notifications may not be supported on this system.');
}
})
.catch((err) => {
console.error('Error checking status before test:', err);
alert('Could not check notification status before testing.');
});
} else {
import('../../../lib/push').then(({ showLocalNotification }) => {
showLocalNotification({
@@ -168,7 +177,7 @@ const NotificationsSettings = () => {
<span className={styles.permissionStatusRequesting}>Click "Allow" to enable notifications</span>
</span>
)}
{permissionStatus === 'granted' && (
{showSuccessMessage && permissionStatus === 'granted' && enableLocalNotifications && (
<span className={styles.permissionStatus} data-status={permissionStatus}>
<span className={styles.permissionStatusSuccess}>
Success! You're done.

View File

@@ -179,7 +179,7 @@ const PlebbitDataPathSettings = ({ plebbitDataPathRef }: SettingsProps) => {
);
};
const isElectron = window.isElectron === true;
const isElectron = window.electronApi?.isElectron === true;
const PlebbitOptions = () => {
const { t } = useTranslation();

View File

@@ -18,7 +18,7 @@ import { FormattingHelpTable } from '../../components/reply-form/reply-form';
import styles from './submit-page.module.css';
const isAndroid = Capacitor.getPlatform() === 'android';
const isElectron = window.isElectron === true;
const isElectron = window.electronApi?.isElectron === true;
const UrlField = () => {
const { t } = useTranslation();

View File

@@ -347,7 +347,7 @@ const JSONSettings = ({ isReadOnly = false }: { isReadOnly?: boolean }) => {
);
};
const isElectron = window.isElectron === true;
const isElectron = window.electronApi?.isElectron === true;
const SubplebbitSettings = () => {
const { t } = useTranslation();

View File

@@ -37,6 +37,9 @@ export default defineConfig({
VitePWA({
registerType: 'autoUpdate',
strategies: 'injectManifest',
injectManifest: {
maximumFileSizeToCacheInBytes: 6000000,
},
srcDir: 'src',
filename: 'sw.ts',
devOptions: {
@@ -74,7 +77,6 @@ export default defineConfig({
clientsClaim: true,
skipWaiting: true,
cleanupOutdatedCaches: true,
maximumFileSizeToCacheInBytes: 6000000,
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/, /^\/_(.*)/],

View File

@@ -3718,22 +3718,6 @@
uuid "8.3.2"
zustand "4.0.0"
"@plebbit/plebbit-react-hooks@https://github.com/plebbit/plebbit-react-hooks.git#19f82d4a5763c53fadf40c87bc1d830c111788f2":
version "0.0.1"
resolved "https://github.com/plebbit/plebbit-react-hooks.git#19f82d4a5763c53fadf40c87bc1d830c111788f2"
dependencies:
"@plebbit/plebbit-js" "https://github.com/plebbit/plebbit-js.git#26c097afa62a87c9650dcdfad52babbc77744cbc"
"@plebbit/plebbit-logger" "https://github.com/plebbit/plebbit-logger.git"
assert "2.0.0"
ethers "5.6.9"
localforage "1.10.0"
lodash.isequal "4.5.0"
memoizee "0.4.15"
quick-lru "5.1.1"
uint8arrays "3.1.1"
uuid "8.3.2"
zustand "4.0.0"
"@plebbit/proper-lockfile@github:plebbit/node-proper-lockfile#7fd6332117340c1d3d98dd0afee2d31cc06f72b8":
version "4.1.2"
resolved "https://codeload.github.com/plebbit/node-proper-lockfile/tar.gz/7fd6332117340c1d3d98dd0afee2d31cc06f72b8"