diff --git a/README.md b/README.md
index f973a26..76676b5 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,56 @@
-# whoisit
+# Who Is It?
-A lightweight Electron UI for the platform services.
+Who Is It? is the Electron investigation workspace for the OSINT Services Platform.
-## What it does
+## Experience
-The UI can:
-- probe the platform services over HTTP,
-- attempt to start the Docker Compose stack from the parent platform repository when needed, and
-- call the platform endpoints for username scanning, profile inspection, and phone lookup.
+The application is organized into four work areas:
-## Running locally
+- **Search** accepts usernames and phone numbers in one workspace. Auto mode routes clearly formatted phone numbers to phone services and other values to profile services; explicit Profile and Phone modes handle ambiguous identifiers. The source filter can query all sources, only connected live APIs, or only imported datasets. Profile scans retain useful public evidence when paid X inspection has no credits, while phone results combine Twilio metadata with exact E.164 dataset matches.
+- **Datasets** provides one entity ingestion pipeline for CSV, JSON, JSONL, or NDJSON. It previews rows, auto-maps familiar columns, and accepts sparse records containing a username, profile URL, phone number, or any combination of those identifiers.
+- **Integrations** shows whether X/Tweepy, Twilio Lookup, and local datasets are configured and reachable, and provides credential replacement fields.
+- **History** stores the last 50 searches locally and can rerun them. History never leaves the device.
-1. Install the dependencies:
- `npm install`
-2. Start the app:
- `npm start`
+Provider failures are isolated by source. For example, a Twilio error appears alongside the current phone search while matching imported records remain usable. Selecting **Datasets only** prevents live-provider calls, which is useful when conserving API usage.
-The UI calls the running platform services through the nginx proxy on port `80`:
-- `/scan/{username}` for username scanning
-- `/focus?url=...` for profile inspection
-- `/phone_search?phone_number=...` for caller lookup
+## Run and test
-## UI screenshot
+Start the parent platform stack first, then:
-If you add a screenshot later, place it in the `docs/` directory and name it `ui-screenshot.png`.
+```bash
+npm install
+npm start
+```
+
+Useful commands:
+
+```bash
+npm test # CSV/JSON/JSONL parser tests
+npm run package # unpacked desktop application
+npm run make # distributable artifacts
+```
+
+Development tools are closed by default. Set `WHOISIT_DEVTOOLS=1` before `npm start` when you want Electron DevTools to open automatically.
+
+## Integration credentials
+
+The Integrations workspace can save an X API bearer token, Twilio Account SID, and Twilio Auth Token to the parent platform's ignored `.env` file. Existing values are never returned to the renderer or displayed. Blank fields preserve their current values, writes are atomic, and the resulting file is restricted to the current operating-system user.
+
+After saving, the application attempts to recreate only the affected Compose services. If the desktop process cannot access Docker, the credentials remain saved and the UI asks the user to restart the platform stack manually.
+
+## Data imports
+
+Files are parsed locally in the renderer and sent through nginx to `/datasets/import` as mapped entity rows. Imports are limited to 10 MB and 10,000 records. Every row requires at least one `Username`, `Profile URL`, or E.164 `Phone number`; all other mappings are optional. A row containing both profile and phone fields creates one entity with typed associated identifiers.
+
+Imported results show associated identifiers alongside dataset provenance, source, observation time, confidence, normalized metadata, and the original row. Dataset files may contain sensitive or licensed data; users are responsible for access, retention, and permitted use.
+
+## Service integration
+
+The application talks to `http://127.0.0.1:80`:
+
+- `/scan/{username}`
+- `/focus?url=...`
+- `/phone_search?phone_number=...`
+- `/datasets/...`
+
+The main process checks all four services and can invoke the parent repository's `scripts/start.sh` when the stack is unavailable.
diff --git a/package.json b/package.json
index 7a52b82..caab59b 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "whoisit",
"productName": "whoisit",
"version": "1.0.0",
- "description": "My Electron application description",
+ "description": "A unified investigation workspace for public profile, phone, and imported dataset searches.",
"main": ".webpack/main",
"scripts": {
"start": "electron-forge start",
@@ -10,7 +10,7 @@
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "echo \"No linting configured\"",
- "test": "echo \"No tests configured\""
+ "test": "node --test tests/*.test.cjs"
},
"devDependencies": {
"@babel/core": "^7.26.0",
diff --git a/src/app.jsx b/src/app.jsx
index 24b04ee..26ecd12 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -1,316 +1,1130 @@
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
Alert,
+ AppBar,
+ Avatar,
Box,
Button,
Card,
CardContent,
+ Chip,
CircularProgress,
Container,
+ Divider,
Grid,
+ IconButton,
+ Link,
+ MenuItem,
Paper,
Stack,
+ Tab,
+ Tabs,
TextField,
+ Toolbar,
+ Tooltip,
Typography,
} from '@mui/material';
-import { PersonSearch, PhoneEnabled, Search } from '@mui/icons-material';
+import {
+ CloudDone,
+ ContentCopy,
+ Dataset,
+ DeleteOutline,
+ ExpandMore,
+ History,
+ Key,
+ PersonSearch,
+ PhoneEnabled,
+ Refresh,
+ Search,
+ Storage,
+ UploadFile,
+} from '@mui/icons-material';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
-const API_BASE = 'http://127.0.0.1:80';
-
-function usePlatformStatus() {
- const [status, setStatus] = React.useState({ ready: false, started: false, checking: true, message: 'Checking whether the platform services are available...' });
-
- const refresh = React.useCallback(async () => {
- if (!window.electronAPI?.ensurePlatform) {
- setStatus({ ready: true, started: false, checking: false, message: 'Electron bridge is unavailable; using direct API calls.' });
- return;
- }
-
- setStatus((current) => ({ ...current, checking: true }));
- const result = await window.electronAPI.ensurePlatform();
- setStatus({ ready: Boolean(result?.ready), started: Boolean(result?.started), checking: false, message: result?.message || 'Platform status unknown.' });
- }, []);
+const {
+ getColumns,
+ parseDatasetContent,
+ suggestMapping,
+} = require('./dataImport.cjs');
+const {
+ normalizeSearchQuery,
+ resolveSearchType,
+ sourceEnabled,
+} = require('./searchMode.cjs');
- React.useEffect(() => {
- refresh();
- }, [refresh]);
+const API_BASE = 'http://127.0.0.1:80';
+const HISTORY_KEY = 'whoisit:search-history';
+const tabs = [
+ { label: 'Search', icon: },
+ { label: 'Datasets', icon: },
+ { label: 'Integrations', icon: },
+ { label: 'History', icon: },
+];
- return { status, refresh };
-}
+const theme = createTheme({
+ palette: {
+ mode: 'dark',
+ primary: { main: '#7dd3c7' },
+ secondary: { main: '#91b8ff' },
+ background: { default: '#09111f', paper: '#111c2d' },
+ },
+ shape: { borderRadius: 12 },
+ typography: {
+ fontFamily: '"Inter", "Segoe UI", system-ui, sans-serif',
+ h4: { fontWeight: 700, letterSpacing: '-0.03em' },
+ h5: { fontWeight: 650, letterSpacing: '-0.02em' },
+ h6: { fontWeight: 650 },
+ button: { fontWeight: 700, letterSpacing: '0.02em' },
+ },
+ components: {
+ MuiPaper: { styleOverrides: { root: { backgroundImage: 'none' } } },
+ MuiButton: { defaultProps: { disableElevation: true } },
+ },
+});
-async function requestJson(url, options = {}) {
- const response = await fetch(url, {
+async function requestJson(path, options = {}) {
+ const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
Accept: 'application/json',
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {}),
},
});
-
- const data = await response.json().catch(() => null);
+ const data = response.status === 204 ? null : await response.json().catch(() => null);
if (!response.ok) {
const detail = data?.detail;
const message = typeof detail === 'string'
? detail
- : detail && typeof detail === 'object' && 'message' in detail
- ? detail.message
- : `Request failed with ${response.status}`;
- throw new Error(message || 'Request failed');
+ : detail?.message
+ ? `${detail.message}${detail.twilio_code ? ` (Twilio code ${detail.twilio_code})` : ''}`
+ : `Request failed with HTTP ${response.status}`;
+ throw new Error(message);
}
-
return data;
}
-function ResultCard({ result, onInspect }) {
+const humanize = (value) => String(value)
+ .replaceAll('_', ' ')
+ .replace(/\b\w/g, (character) => character.toUpperCase());
+
+const present = (value) => value !== null && value !== undefined && value !== '';
+
+function usePlatformStatus() {
+ const [status, setStatus] = React.useState({
+ ready: false,
+ checking: true,
+ message: 'Checking platform services…',
+ });
+ const refresh = React.useCallback(async () => {
+ setStatus((current) => ({ ...current, checking: true }));
+ if (!window.electronAPI?.ensurePlatform) {
+ setStatus({ ready: true, checking: false, message: 'Connected through direct API calls.' });
+ return;
+ }
+ const result = await window.electronAPI.ensurePlatform();
+ setStatus({
+ ready: Boolean(result?.ready),
+ checking: false,
+ message: result?.message || 'Platform status unknown.',
+ });
+ }, []);
+ React.useEffect(() => { refresh(); }, [refresh]);
+ return { status, refresh };
+}
+
+function loadHistory() {
+ try {
+ return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
+ } catch {
+ return [];
+ }
+}
+
+function DetailItem({ label, value, href }) {
+ const display = !present(value) ? 'Not provided' : typeof value === 'boolean' ? (value ? 'Yes' : 'No') : String(value);
return (
-
-
-
- {result.title || 'Unknown result'}
-
-
- {result.validation_uri || result.profile_uri || 'No URI available'}
-
-
- Valid profile: {String(result.is_valid_profile)}
-
-
+
+ {label}
+
+ {href && present(value)
+ ? {display}
+ : display}
+
+
+ );
+}
+
+function Provenance({ record, liveLabel }) {
+ const isDataset = record?.source_type === 'dataset';
+ return (
+
+
+ : }
+ label={isDataset ? record.dataset_name || 'Imported dataset' : liveLabel}
+ color={isDataset ? 'secondary' : 'primary'}
+ size="small"
+ />
+ {present(record?.source) && }
+ {present(record?.observed_at) && }
+ {present(record?.confidence) && }
+
+
+ );
+}
+
+function RawDetails({ value }) {
+ if (!value) return null;
+ return (
+
+ }>
+ Raw source record
+
+
+
+ {JSON.stringify(value, null, 2)}
+
+
+
+ );
+}
+
+function EntityAssociations({ entity, currentType }) {
+ if (!entity) return null;
+ const profiles = currentType === 'profile' ? [] : entity.profiles || [];
+ const phones = currentType === 'phone' ? [] : entity.phone_numbers || [];
+ if (!profiles.length && !phones.length) return null;
+ return (
+
+ Associated identifiers
+
+ {profiles.map((profile) => (
+ }
+ label={`${profile.platform || 'Profile'} · @${profile.username}`}
+ component={profile.profile_uri ? Link : 'div'}
+ href={profile.profile_uri || undefined}
+ target={profile.profile_uri ? '_blank' : undefined}
+ clickable={Boolean(profile.profile_uri)}
+ variant="outlined"
+ />
+ ))}
+ {phones.map((phone) => (
+ }
+ label={phone.phone_number}
+ variant="outlined"
+ />
+ ))}
+
+
+ );
+}
+
+function CopyButton({ value }) {
+ const [copied, setCopied] = React.useState(false);
+ const copy = async () => {
+ await navigator.clipboard.writeText(JSON.stringify(value, null, 2));
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1200);
+ };
+ return (
+
+
+
+ );
+}
+
+function ProfileDetail({ profile, loading, error }) {
+ if (loading) return Loading public profile…;
+ if (error) return {error};
+ if (!profile) return ;
+ return (
+
+ {profile.profile_banner_url && (
+
+ )}
+
+
+
+ {profile.name || profile.username || 'Unknown profile'}
+ {profile.username ? `@${profile.username}` : profile.platform}
+
+
+
+
+ {present(profile.verified) && }
+ {present(profile.protected) && }
+ {profile.verified_type && }
+
+
+
+ {profile.bio || 'No bio provided.'}
+
+
+ {[
+ ['Platform', profile.platform || 'X'],
+ ['User ID', profile.id],
+ ['Location', profile.location],
+ ['Created', profile.created_at],
+ ['Website', profile.website, profile.website],
+ ['Profile URL', profile.profile_uri, profile.profile_uri],
+ ['Pinned post', profile.pinned_tweet_id],
+ ['Latest post', profile.most_recent_tweet_id],
+ ].map(([label, value, href]) => (
+
+ ))}
+
+ {profile.metrics && Object.keys(profile.metrics).length > 0 && (
+
+ Public metrics
+
+ {Object.entries(profile.metrics).map(([key, value]) => (
+
+
+ {typeof value === 'number' ? new Intl.NumberFormat().format(value) : value}
+ {humanize(key)}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+function PhoneDetail({ phone }) {
+ if (!phone) return ;
+ return (
+
+
+
+ {phone.caller_name || 'Unknown caller'}
+ {phone.phone_number}
+
+
+
+
+ {present(phone.valid) && }
+ {phone.line_type && }
+ {phone.caller_type && }
+
+
+
+
+ {[
+ ['E.164 number', phone.phone_number],
+ ['National format', phone.national_format],
+ ['Country', phone.country_code],
+ ['Caller name', phone.caller_name],
+ ['Caller type', phone.caller_type],
+ ['Carrier', phone.carrier_name],
+ ['Line type', phone.line_type],
+ ['Location', phone.location],
+ ].map(([label, value]) => (
+
+ ))}
+
+
+
+ );
+}
+
+function EmptyState({ title, body }) {
+ return (
+
+
+ {title}
+ {body}
+
+ );
+}
+
+function ResultRow({ title, subtitle, source, selected, onClick, action, children }) {
+ return (
+
+
+
+
+ {title}
+ {subtitle}
+
+
+
+ {children && {children}}
+ {action && {action}}
);
}
-const theme = createTheme({
- palette: {
- mode: 'dark',
- },
-});
+function SearchShell({ title, description, input, controls, button, context, error, notices, results, detail }) {
+ return (
+
+
+ {title}
+ {description}
+
+
+ {input}{controls}{button}
+ {context && {context}}
+ {error && {error}}
+ {notices?.map((notice) => {notice})}
+
+
+
+
+ Results
+ {results}
+
+
+
+ {detail}
+
+
+
+ );
+}
-function SearchPage() {
- const { status, refresh } = usePlatformStatus();
- const [username, setUsername] = React.useState('');
- const [searchResults, setSearchResults] = React.useState([]);
- const [searching, setSearching] = React.useState(false);
- const [profileDetails, setProfileDetails] = React.useState(null);
- const [profileError, setProfileError] = React.useState('');
- const [phoneNumber, setPhoneNumber] = React.useState('');
- const [phoneData, setPhoneData] = React.useState(null);
- const [phoneSearching, setPhoneSearching] = React.useState(false);
+function DatasetWorkspace({ datasets, reloadDatasets }) {
+ const [schema, setSchema] = React.useState(null);
+ const [fileState, setFileState] = React.useState(null);
+ const [mapping, setMapping] = React.useState({});
+ const [name, setName] = React.useState('');
const [error, setError] = React.useState('');
+ const [result, setResult] = React.useState(null);
+ const [importing, setImporting] = React.useState(false);
+
+ React.useEffect(() => {
+ requestJson('/datasets/schema/entity')
+ .then(setSchema)
+ .catch((reason) => setError(reason.message));
+ }, []);
+
+ React.useEffect(() => {
+ if (schema && fileState) setMapping(suggestMapping(schema.fields, fileState.columns));
+ }, [schema, fileState]);
- const ensurePlatformReady = async () => {
- if (window.electronAPI?.ensurePlatform) {
- const result = await window.electronAPI.ensurePlatform();
- const nextStatus = {
- ready: Boolean(result?.ready),
- started: Boolean(result?.started),
- checking: false,
- message: result?.message || 'Platform status unknown.',
- };
- return nextStatus;
+ const chooseFile = async (event) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ setError('');
+ setResult(null);
+ try {
+ if (file.size > 10 * 1024 * 1024) throw new Error('Import files are limited to 10 MB.');
+ const rows = parseDatasetContent(file.name, await file.text());
+ setFileState({ filename: file.name, rows, columns: getColumns(rows) });
+ setName(file.name.replace(/\.(csv|jsonl?|ndjson)$/i, ''));
+ } catch (reason) {
+ setFileState(null);
+ setError(reason.message);
}
- return { ready: true, started: false, checking: false, message: 'Ready to use.' };
};
- const handleScan = async () => {
- if (!username.trim()) {
- setError('Enter a username to scan first.');
+ const importData = async () => {
+ if (!fileState || !name.trim()) {
+ setError('Choose a file and provide a dataset name.');
return;
}
-
- setError('');
- const readiness = await ensurePlatformReady();
- if (!readiness.ready) {
- setError(readiness.message || 'The platform could not be started.');
+ if (!mapping.username && !mapping.profile_url && !mapping.phone_number) {
+ setError('Map at least one searchable identifier: Username, Profile URL, or Phone number.');
return;
}
-
- setSearching(true);
- setSearchResults([]);
- setProfileDetails(null);
+ setImporting(true);
+ setError('');
try {
- const data = await requestJson(`${API_BASE}/scan/${encodeURIComponent(username.trim())}`);
- setSearchResults(Array.isArray(data) ? data : []);
- } catch (err) {
- setError(err.message || 'Unable to scan that username.');
+ const imported = await requestJson('/datasets/import', {
+ method: 'POST',
+ body: JSON.stringify({
+ name: name.trim(),
+ record_type: 'entity',
+ filename: fileState.filename,
+ mapping,
+ rows: fileState.rows,
+ }),
+ });
+ setResult(imported);
+ await reloadDatasets();
+ } catch (reason) {
+ setError(reason.message);
} finally {
- setSearching(false);
+ setImporting(false);
}
};
- const handleInspect = async (profileUrl) => {
- if (!profileUrl) {
- setProfileError('No profile URL available for that result.');
- return;
- }
+ const removeDataset = async (id) => {
+ if (!window.confirm('Delete this dataset and all of its records?')) return;
+ await requestJson(`/datasets/${id}`, { method: 'DELETE' });
+ await reloadDatasets();
+ };
- const readiness = await ensurePlatformReady();
- if (!readiness.ready) {
- setProfileError(readiness.message || 'The platform could not be started.');
- return;
- }
+ return (
+
+
+ Datasets
+ Import sparse identity records through one pipeline. Each row can contain a profile, a phone number, or both, and raw source rows remain available for audit.
+
+
+
+
+
+
+ Import records
+ CSV, JSON, JSONL, or NDJSON · up to 10,000 rows / 10 MB
+
+ }>
+ Choose file
+
+
+
+
+ setName(event.target.value)} />
+ {fileState && (
+ <>
+ {fileState.filename}: {fileState.rows.length.toLocaleString()} rows, {fileState.columns.length} columns
+
+ Map any available fields. Every imported row needs at least one username, profile URL, or E.164 phone number; the other fields are optional.
+
+
+ Map source columns
+
+ {schema?.fields.map((field) => (
+
+ setMapping((current) => ({ ...current, [field.key]: event.target.value }))}
+ >
+
+ {fileState.columns.map((column) => )}
+
+
+ ))}
+
+
+
+ }>Preview first five records
+ {JSON.stringify(fileState.rows.slice(0, 5), null, 2)}
+
+ : }>
+ {importing ? 'Importing…' : 'Import dataset'}
+
+ >
+ )}
+ {error && {error}}
+ {result && (
+
+ Imported {result.imported.toLocaleString()} entities with {result.profile_identifiers.toLocaleString()} profile and {result.phone_identifiers.toLocaleString()} phone identifiers. Rejected {result.rejected.toLocaleString()}.
+ {result.rejected_rows?.length > 0 && ` First issue: row ${result.rejected_rows[0].row_number}: ${result.rejected_rows[0].reason}`}
+
+ )}
+
+
+
+
+
+ Available sources
+ {datasets.length} imported dataset{datasets.length === 1 ? '' : 's'}
+
+ {datasets.map((dataset) => (
+
+
+
+
+ {dataset.name}
+ {dataset.record_type === 'entity' ? 'Entities' : humanize(dataset.record_type)} · {dataset.row_count.toLocaleString()} records
+ {new Date(dataset.imported_at).toLocaleString()}
+
+ removeDataset(dataset.id)}>
+
+ {dataset.rejected_count > 0 && }
+
+
+ ))}
+ {!datasets.length && }
+
+
+
+
+
+ );
+}
- setProfileError('');
- setProfileDetails(null);
+function IntegrationCard({ integration }) {
+ const ready = integration.configured && integration.reachable;
+ const statusLabel = ready
+ ? 'Connected'
+ : integration.configured
+ ? 'Configured · service unavailable'
+ : integration.reachable
+ ? 'Service reachable · credential source unknown'
+ : 'Not connected';
+ return (
+
+
+
+
+ {integration.name}
+ {integration.client}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function IntegrationsWorkspace({ onPlatformRefresh }) {
+ const [status, setStatus] = React.useState(null);
+ const [credentials, setCredentials] = React.useState({
+ TWEEPY_BEARER_TOKEN: '',
+ TWILIO_ACCOUNT_SID: '',
+ TWILIO_AUTH_TOKEN: '',
+ });
+ const [loading, setLoading] = React.useState(true);
+ const [saving, setSaving] = React.useState(false);
+ const [message, setMessage] = React.useState(null);
+
+ const refresh = React.useCallback(async () => {
+ setLoading(true);
try {
- const data = await requestJson(`${API_BASE}/focus?url=${encodeURIComponent(profileUrl)}`);
- setProfileDetails(data);
- } catch (err) {
- setProfileError(err.message || 'Unable to inspect that profile.');
+ if (!window.electronAPI?.getIntegrations) {
+ throw new Error('Integration settings are available in the Electron application.');
+ }
+ setStatus(await window.electronAPI.getIntegrations());
+ } catch (reason) {
+ setMessage({ severity: 'error', text: reason.message });
+ } finally {
+ setLoading(false);
}
- };
+ }, []);
- const handlePhoneLookup = async () => {
- if (!phoneNumber.trim()) {
- setError('Enter a phone number in E.164 format.');
- return;
- }
+ React.useEffect(() => { refresh(); }, [refresh]);
- const readiness = await ensurePlatformReady();
- if (!readiness.ready) {
- setError(readiness.message || 'The platform could not be started.');
- return;
- }
+ const updateCredential = (key) => (event) => {
+ setCredentials((current) => ({ ...current, [key]: event.target.value }));
+ };
- setError('');
- setPhoneSearching(true);
- setPhoneData(null);
+ const save = async () => {
+ setSaving(true);
+ setMessage(null);
try {
- const data = await requestJson(`${API_BASE}/phone_search?phone_number=${encodeURIComponent(phoneNumber.trim())}`);
- setPhoneData(data);
- } catch (err) {
- setError(err.message || 'Unable to look up that number.');
+ const result = await window.electronAPI.saveIntegrations(credentials);
+ setStatus(result.status);
+ setCredentials({
+ TWEEPY_BEARER_TOKEN: '',
+ TWILIO_ACCOUNT_SID: '',
+ TWILIO_AUTH_TOKEN: '',
+ });
+ setMessage({
+ severity: result.saved && result.restarted ? 'success' : result.saved ? 'warning' : 'info',
+ text: result.message,
+ });
+ await onPlatformRefresh();
+ } catch (reason) {
+ setMessage({ severity: 'error', text: reason.message });
} finally {
- setPhoneSearching(false);
+ setSaving(false);
}
};
+ const hasNewValues = Object.values(credentials).some((value) => value.trim());
+
return (
-
-
+
+
-
- Who Is It?
-
-
- Search usernames, inspect public profiles, and resolve caller metadata from the platform services.
-
+ Integrations
+ See which data providers are configured and update the credentials used by the local platform.
+ : }>
+ Refresh status
+
+
-
-
- Platform status
-
-
-
- {status.checking ? 'Checking whether the platform services are available...' : status.message}
-
-
-
-
-
- Username scan
-
-
- setUsername(event.target.value)}
- onKeyDown={(event) => event.key === 'Enter' && handleScan()}
- />
- : }
- >
- {searching ? 'Scanning...' : 'Scan'}
-
-
- {error && {error}}
-
- {searchResults.map((result) => (
-
-
-
- ))}
+ {message && {message.text}}
+
+
+ {(status?.integrations || []).map((integration) => (
+
+
-
-
-
-
- Profile inspection
-
- {profileError && {profileError}}
- {profileDetails ? (
-
-
- Name: {profileDetails.name || profileDetails.username || 'Unknown'}
-
-
- {profileDetails.bio || 'No description available.'}
-
-
- ) : (
-
- Select an inspection action from a scan result to query the profile search service.
+ ))}
+ {loading && !status && (
+
+ )}
+
+
+
+ Provider credentials
+
+ Existing values are never sent to or displayed by the UI. Leave a field blank to keep its current value.
+ New values are stored in the ignored platform .env file with owner-only permissions.
+
+
+
+ X API via Tweepy
+
+ Used for expanded public profile inspection.
- )}
-
-
-
-
- Phone lookup
-
-
setPhoneNumber(event.target.value)}
- placeholder="+18135551212"
+ type="password"
+ autoComplete="new-password"
+ label="X API bearer token"
+ placeholder={status?.integrations?.find((item) => item.id === 'x')?.configured ? 'Configured — enter a value only to replace it' : 'Enter bearer token'}
+ value={credentials.TWEEPY_BEARER_TOKEN}
+ onChange={updateCredential('TWEEPY_BEARER_TOKEN')}
/>
- : }
- >
- {phoneSearching ? 'Checking...' : 'Lookup'}
-
-
- {phoneData && (
-
-
- Caller: {phoneData.caller_name || 'No caller name returned'}
-
-
- Valid: {String(phoneData.valid)} • {phoneData.country_code || 'Unknown country'}
-
-
- )}
-
-
-
+
+
+
+ Twilio Lookup
+
+ Used for live caller-name and phone metadata lookup.
+
+
+
+
+
+
+
+
+
+
+
+
+ “Connected” means the credential is configured and its local API service passes readiness. It does not make a billable provider request.
+
+ : }>
+ {saving ? 'Saving and applying…' : 'Save and apply credentials'}
+
+
+
);
}
function App() {
+ const { status, refresh } = usePlatformStatus();
+ const [tab, setTab] = React.useState(0);
+ const [query, setQuery] = React.useState('');
+ const [searchType, setSearchType] = React.useState('auto');
+ const [resolvedType, setResolvedType] = React.useState('profile');
+ const [sourceScope, setSourceScope] = React.useState('all');
+ const [usernameResults, setUsernameResults] = React.useState([]);
+ const [phoneResults, setPhoneResults] = React.useState([]);
+ const [selectedProfile, setSelectedProfile] = React.useState(null);
+ const [selectedPhone, setSelectedPhone] = React.useState(null);
+ const [profileLoading, setProfileLoading] = React.useState(false);
+ const [searching, setSearching] = React.useState(false);
+ const [searchError, setSearchError] = React.useState('');
+ const [profileError, setProfileError] = React.useState('');
+ const [notices, setNotices] = React.useState([]);
+ const [history, setHistory] = React.useState(loadHistory);
+ const [datasets, setDatasets] = React.useState([]);
+
+ const reloadDatasets = React.useCallback(async () => {
+ try { setDatasets(await requestJson('/datasets')); } catch { /* status surface handles availability */ }
+ }, []);
+ React.useEffect(() => { reloadDatasets(); }, [reloadDatasets]);
+
+ const addHistory = (type, query, count) => {
+ const next = [{ id: `${Date.now()}-${type}`, type, query, count, searchedAt: new Date().toISOString() }, ...history].slice(0, 50);
+ setHistory(next);
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(next));
+ };
+
+ const inspectProfile = async (profileUrl) => {
+ setProfileLoading(true);
+ setProfileError('');
+ try {
+ const profile = await requestJson(`/focus?url=${encodeURIComponent(profileUrl)}`);
+ setSelectedProfile({ ...profile, source_type: 'live', platform: 'X', profile_uri: profileUrl });
+ } catch (reason) {
+ setProfileError(reason.message);
+ setSelectedProfile(null);
+ } finally {
+ setProfileLoading(false);
+ }
+ };
+
+ const runProfile = async (searchQuery, scope) => {
+ setSearching(true);
+ setSearchError('');
+ setProfileError('');
+ setNotices([]);
+ setSelectedProfile(null);
+ const requests = [];
+ const requestTypes = [];
+ if (sourceEnabled(scope, 'live')) {
+ requests.push(requestJson(`/scan/${encodeURIComponent(searchQuery)}`));
+ requestTypes.push('live');
+ }
+ if (sourceEnabled(scope, 'datasets')) {
+ requests.push(requestJson(`/datasets/search/profiles?query=${encodeURIComponent(searchQuery)}&fuzzy=true`));
+ requestTypes.push('datasets');
+ }
+ const settled = await Promise.allSettled(requests);
+ const byType = Object.fromEntries(requestTypes.map((type, index) => [type, settled[index]]));
+ const live = byType.live;
+ const local = byType.datasets;
+ const next = [
+ ...(live?.status === 'fulfilled' && Array.isArray(live.value)
+ ? live.value.map((record) => ({ ...record, result_kind: 'scan' }))
+ : []),
+ ...(local?.status === 'fulfilled'
+ ? local.value.records.map((record) => ({ ...record, result_kind: 'dataset' }))
+ : []),
+ ];
+ setUsernameResults(next);
+ setNotices([
+ ...(live?.status === 'rejected' ? [`Live profile scan: ${live.reason.message}`] : []),
+ ...(local?.status === 'rejected' ? [`Imported datasets: ${local.reason.message}`] : []),
+ ]);
+ addHistory('profile', searchQuery, next.length);
+ setSearching(false);
+ };
+
+ const runPhone = async (searchQuery, scope) => {
+ setSearching(true);
+ setSearchError('');
+ setProfileError('');
+ setNotices([]);
+ setSelectedPhone(null);
+ const requests = [];
+ const requestTypes = [];
+ if (sourceEnabled(scope, 'live')) {
+ requests.push(requestJson(`/phone_search?phone_number=${encodeURIComponent(searchQuery)}`));
+ requestTypes.push('live');
+ }
+ if (sourceEnabled(scope, 'datasets')) {
+ requests.push(requestJson(`/datasets/search/phones?phone_number=${encodeURIComponent(searchQuery)}`));
+ requestTypes.push('datasets');
+ }
+ const settled = await Promise.allSettled(requests);
+ const byType = Object.fromEntries(requestTypes.map((type, index) => [type, settled[index]]));
+ const live = byType.live;
+ const local = byType.datasets;
+ const next = [
+ ...(live?.status === 'fulfilled' ? [{ ...live.value, source_type: 'live', result_kind: 'live' }] : []),
+ ...(local?.status === 'fulfilled' ? local.value.records.map((record) => ({ ...record, result_kind: 'dataset' })) : []),
+ ];
+ setPhoneResults(next);
+ setSelectedPhone(next[0] || null);
+ setNotices([
+ ...(live?.status === 'rejected' ? [`Live phone lookup: ${live.reason.message}`] : []),
+ ...(local?.status === 'rejected' ? [`Imported datasets: ${local.reason.message}`] : []),
+ ]);
+ addHistory('phone', searchQuery, next.length);
+ setSearching(false);
+ };
+
+ const runSearch = async (override, typeOverride, scopeOverride) => {
+ const rawQuery = String(override ?? query).trim();
+ const selectedType = typeOverride ?? searchType;
+ const scope = scopeOverride ?? sourceScope;
+ if (!rawQuery) {
+ setSearchError('Enter a username or phone number to search.');
+ return;
+ }
+ const type = resolveSearchType(rawQuery, selectedType);
+ const normalized = normalizeSearchQuery(rawQuery, type);
+ setQuery(normalized);
+ setResolvedType(type);
+ setSearchError('');
+ if (type === 'phone') {
+ await runPhone(normalized, scope);
+ } else {
+ await runProfile(normalized, scope);
+ }
+ };
+
+ const rerun = (item) => {
+ const type = item.type === 'username' ? 'profile' : item.type;
+ setTab(0);
+ setSearchType(type);
+ setQuery(item.query);
+ runSearch(item.query, type, sourceScope);
+ };
+
+ const previewType = searchType === 'auto' && query.trim()
+ ? resolveSearchType(query, 'auto')
+ : resolvedType;
+
return (
-
+
+
+
+ Who Is It?
+ Investigation workspace
+
+
+ : }
+ label={status.checking ? 'Checking services' : status.ready ? 'Services online' : 'Service issue'}
+ color={status.ready ? 'success' : 'warning'}
+ variant="outlined"
+ onClick={refresh}
+ />
+
+
+
+
+ { setTab(value); setSearchError(''); setProfileError(''); setNotices([]); }} variant="scrollable" scrollButtons="auto">
+ {tabs.map((item) => )}
+
+
+
+
+ {tab === 0 && (
+ setQuery(event.target.value)}
+ onKeyDown={(event) => event.key === 'Enter' && runSearch()}
+ />
+ )}
+ controls={(
+ <>
+ {
+ const nextType = event.target.value;
+ setSearchType(nextType);
+ if (nextType !== 'auto') setResolvedType(nextType);
+ }}
+ sx={{ minWidth: 150 }}
+ >
+
+
+
+
+ setSourceScope(event.target.value)}
+ sx={{ minWidth: 160 }}
+ >
+
+
+
+
+ >
+ )}
+ button={(
+
+ )}
+ context={(
+
+ : }
+ label={searchType === 'auto'
+ ? `Auto routes this search to ${previewType === 'phone' ? 'phone' : 'profile'} services`
+ : `${resolvedType === 'phone' ? 'Phone' : 'Profile'} search selected`}
+ variant="outlined"
+ />
+
+ Source filters control which services are called, helping avoid unnecessary provider usage.
+
+
+ )}
+ error={searchError}
+ notices={notices}
+ results={resolvedType === 'phone'
+ ? phoneResults.length
+ ? phoneResults.map((record, index) => (
+ setSelectedPhone(record)}
+ >
+
+
+
+
+
+ {record.entity?.profiles?.length > 0 && (
+
+ )}
+
+
+ ))
+ :
+ : usernameResults.length
+ ? usernameResults.map((record, index) => {
+ const local = record.result_kind === 'dataset';
+ return (
+ { setProfileError(''); setSelectedProfile(record); } : undefined}
+ action={!local && record.is_valid_profile
+ ? } onClick={() => inspectProfile(record.profile_uri)}>Inspect profile
+ : null}
+ >
+ {local ? (
+
+ {record.bio && (
+
+ {record.bio}
+
+ )}
+
+
+
+
+
+ {record.entity?.phone_numbers?.length > 0 && (
+
+ )}
+
+ {record.observed_at && (
+
+ Observed {new Date(record.observed_at).toLocaleString()}
+
+ )}
+
+ ) : (
+
+
+
+ {record.validation?.reason && }
+
+ {record.validation?.msg && {record.validation.msg}}
+ {record.validation?.desc && {record.validation.desc}}
+
+
+
+ This public scan evidence remains available even when expanded X API inspection has no credits.
+
+
+ )}
+
+ );
+ })
+ : }
+ detail={resolvedType === 'phone'
+ ?
+ : }
+ />
+ )}
+ {tab === 1 && }
+ {tab === 2 && }
+ {tab === 3 && (
+
+
+ Search historyRecent searches stay on this device and can be rerun with one click.
+
+
+
+
+ {history.map((item) => (
+
+
+
+
+ {item.query}
+ {item.count} results · {new Date(item.searchedAt).toLocaleString()}
+
+ } onClick={() => rerun(item)}>Run again
+
+
+
+ ))}
+ {!history.length && }
+
+
+
+ )}
+
);
}
-const root = createRoot(document.body);
-root.render();
\ No newline at end of file
+createRoot(document.getElementById('root')).render();
diff --git a/src/dataImport.cjs b/src/dataImport.cjs
new file mode 100644
index 0000000..f8d598a
--- /dev/null
+++ b/src/dataImport.cjs
@@ -0,0 +1,145 @@
+const MAX_ROWS = 10000;
+
+function parseCsv(text) {
+ const rows = [];
+ let row = [];
+ let value = '';
+ let quoted = false;
+
+ for (let index = 0; index < text.length; index += 1) {
+ const character = text[index];
+ if (quoted) {
+ if (character === '"' && text[index + 1] === '"') {
+ value += '"';
+ index += 1;
+ } else if (character === '"') {
+ quoted = false;
+ } else {
+ value += character;
+ }
+ } else if (character === '"') {
+ quoted = true;
+ } else if (character === ',') {
+ row.push(value);
+ value = '';
+ } else if (character === '\n') {
+ row.push(value.replace(/\r$/, ''));
+ rows.push(row);
+ row = [];
+ value = '';
+ } else {
+ value += character;
+ }
+ }
+
+ if (quoted) {
+ throw new Error('CSV contains an unterminated quoted value.');
+ }
+ if (value || row.length) {
+ row.push(value.replace(/\r$/, ''));
+ rows.push(row);
+ }
+ if (rows.length < 2) {
+ throw new Error('CSV must contain a header and at least one data row.');
+ }
+
+ const headers = rows[0].map((header) => header.trim());
+ if (headers.some((header) => !header)) {
+ throw new Error('Every CSV column must have a header.');
+ }
+ if (new Set(headers).size !== headers.length) {
+ throw new Error('CSV column headers must be unique.');
+ }
+
+ return rows
+ .slice(1)
+ .filter((cells) => cells.some((cell) => cell.trim()))
+ .map((cells) => Object.fromEntries(
+ headers.map((header, index) => [header, cells[index] ?? '']),
+ ));
+}
+
+function parseDatasetContent(filename, text) {
+ const extension = filename.toLowerCase().split('.').pop();
+ let rows;
+
+ if (extension === 'csv') {
+ rows = parseCsv(text);
+ } else if (['jsonl', 'ndjson'].includes(extension)) {
+ rows = text
+ .split(/\r?\n/)
+ .filter((line) => line.trim())
+ .map((line, index) => {
+ try {
+ return JSON.parse(line);
+ } catch {
+ throw new Error(`JSONL row ${index + 1} is not valid JSON.`);
+ }
+ });
+ } else if (extension === 'json') {
+ const parsed = JSON.parse(text);
+ rows = Array.isArray(parsed)
+ ? parsed
+ : Array.isArray(parsed?.records)
+ ? parsed.records
+ : [parsed];
+ } else {
+ throw new Error('Choose a CSV, JSON, JSONL, or NDJSON file.');
+ }
+
+ if (!rows.length) {
+ throw new Error('The file does not contain any records.');
+ }
+ if (rows.length > MAX_ROWS) {
+ throw new Error(`A single import can contain at most ${MAX_ROWS.toLocaleString()} records.`);
+ }
+ if (rows.some((row) => !row || Array.isArray(row) || typeof row !== 'object')) {
+ throw new Error('Every imported record must be an object with named fields.');
+ }
+
+ return rows;
+}
+
+function getColumns(rows) {
+ return [...new Set(rows.flatMap((row) => Object.keys(row)))];
+}
+
+const aliases = {
+ username: ['username', 'user_name', 'handle', 'screen_name'],
+ platform: ['platform', 'network', 'site', 'service'],
+ profile_url: ['profile_url', 'profile_uri', 'url', 'account_url'],
+ display_name: ['display_name', 'full_name', 'name'],
+ bio: ['bio', 'description', 'about'],
+ website: ['website', 'web', 'external_url'],
+ followers_count: ['followers_count', 'followers'],
+ following_count: ['following_count', 'following', 'friends_count'],
+ post_count: ['post_count', 'posts', 'statuses_count'],
+ phone_number: ['phone_number', 'phone', 'number', 'e164'],
+ caller_name: ['caller_name', 'subscriber_name', 'name'],
+ country_code: ['country_code', 'country'],
+ carrier_name: ['carrier_name', 'carrier'],
+ line_type: ['line_type', 'phone_type', 'kind'],
+};
+
+const comparable = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
+
+function suggestMapping(fields, columns) {
+ const mapping = {};
+ fields.forEach((field) => {
+ const candidates = aliases[field.key] || [field.key];
+ const match = columns.find((column) => (
+ candidates.some((candidate) => comparable(candidate) === comparable(column))
+ ));
+ if (match) {
+ mapping[field.key] = match;
+ }
+ });
+ return mapping;
+}
+
+module.exports = {
+ getColumns,
+ parseCsv,
+ parseDatasetContent,
+ suggestMapping,
+};
diff --git a/src/index.css b/src/index.css
index d2718db..e0fa4cf 100644
--- a/src/index.css
+++ b/src/index.css
@@ -3,10 +3,16 @@ body {
Arial, sans-serif;
margin: 0;
min-height: 100vh;
- background: #121212;
+ background:
+ radial-gradient(circle at 15% -20%, rgba(68, 133, 166, 0.22), transparent 36rem),
+ #09111f;
color: #fff;
}
#root {
min-height: 100vh;
}
+
+* {
+ box-sizing: border-box;
+}
diff --git a/src/integrationConfig.cjs b/src/integrationConfig.cjs
new file mode 100644
index 0000000..807f146
--- /dev/null
+++ b/src/integrationConfig.cjs
@@ -0,0 +1,109 @@
+const INTEGRATION_KEYS = [
+ 'TWEEPY_BEARER_TOKEN',
+ 'TWILIO_ACCOUNT_SID',
+ 'TWILIO_AUTH_TOKEN',
+];
+
+function getPlatformRootCandidates(appPath, pathModule) {
+ const candidates = [appPath];
+ let current = appPath;
+ for (let depth = 0; depth < 8; depth += 1) {
+ current = pathModule.resolve(current, '..');
+ candidates.push(current);
+ }
+ return [...new Set(candidates)];
+}
+
+function parseEnv(content) {
+ const values = {};
+ content.split(/\r?\n/).forEach((line) => {
+ const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=(.*)$/);
+ if (!match) return;
+ let value = match[2].trim();
+ if (
+ (value.startsWith('"') && value.endsWith('"'))
+ || (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1);
+ }
+ values[match[1]] = value;
+ });
+ return values;
+}
+
+function isConfigured(value) {
+ if (!value) return false;
+ const normalized = value.toLowerCase();
+ return !normalized.includes('your_') && !normalized.includes('replace_me');
+}
+
+function getConfiguredIntegrations(content) {
+ const values = parseEnv(content);
+ return {
+ x: isConfigured(values.TWEEPY_BEARER_TOKEN),
+ twilio: (
+ isConfigured(values.TWILIO_ACCOUNT_SID)
+ && isConfigured(values.TWILIO_AUTH_TOKEN)
+ ),
+ };
+}
+
+function validateCredentialUpdates(updates) {
+ const clean = {};
+ INTEGRATION_KEYS.forEach((key) => {
+ const raw = updates?.[key];
+ if (raw === undefined || raw === null || String(raw).trim() === '') return;
+ const value = String(raw).trim();
+ if (/[\r\n\0]/.test(value)) {
+ throw new Error(`${key} contains invalid characters.`);
+ }
+ clean[key] = value;
+ });
+
+ if (clean.TWEEPY_BEARER_TOKEN && clean.TWEEPY_BEARER_TOKEN.length < 20) {
+ throw new Error('The X API bearer token appears to be incomplete.');
+ }
+ if (
+ clean.TWILIO_ACCOUNT_SID
+ && !/^AC[a-zA-Z0-9]{16,}$/.test(clean.TWILIO_ACCOUNT_SID)
+ ) {
+ throw new Error('The Twilio Account SID must start with AC.');
+ }
+ if (clean.TWILIO_AUTH_TOKEN && clean.TWILIO_AUTH_TOKEN.length < 16) {
+ throw new Error('The Twilio Auth Token appears to be incomplete.');
+ }
+ return clean;
+}
+
+function updateEnvContent(content, updates) {
+ const clean = validateCredentialUpdates(updates);
+ const remaining = new Map(Object.entries(clean));
+ const lines = content ? content.split(/\r?\n/) : [];
+ const updated = lines.map((line) => {
+ const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=/);
+ if (!match || !remaining.has(match[1])) return line;
+ const key = match[1];
+ const value = remaining.get(key);
+ remaining.delete(key);
+ return `${key}=${value}`;
+ });
+
+ if (remaining.size) {
+ if (updated.length && updated[updated.length - 1] !== '') updated.push('');
+ updated.push('# Saved by the Who Is It? integrations screen.');
+ remaining.forEach((value, key) => updated.push(`${key}=${value}`));
+ }
+
+ return {
+ content: `${updated.join('\n').replace(/\n+$/, '')}\n`,
+ updatedKeys: Object.keys(clean),
+ };
+}
+
+module.exports = {
+ getConfiguredIntegrations,
+ getPlatformRootCandidates,
+ parseEnv,
+ updateEnvContent,
+ validateCredentialUpdates,
+};
diff --git a/src/main.js b/src/main.js
index 4dd6cf3..dc1566d 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,24 +1,24 @@
import { app, BrowserWindow, ipcMain } from 'electron';
-import { existsSync } from 'node:fs';
+import { existsSync, promises as fs } from 'node:fs';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import http from 'node:http';
import https from 'node:https';
import started from 'electron-squirrel-startup';
+const {
+ getConfiguredIntegrations,
+ getPlatformRootCandidates,
+ updateEnvContent,
+} = require('./integrationConfig.cjs');
+
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
app.quit();
}
const findPlatformRoot = () => {
- const candidates = [
- app.getAppPath(),
- path.resolve(app.getAppPath(), '..'),
- path.resolve(app.getAppPath(), '..', '..'),
- path.resolve(app.getAppPath(), '..', '..', '..'),
- path.resolve(app.getAppPath(), '..', '..', '..', '..'),
- ];
+ const candidates = getPlatformRootCandidates(app.getAppPath(), path);
for (const candidate of candidates) {
if (existsSync(path.join(candidate, 'docker-compose.yml')) && existsSync(path.join(candidate, 'scripts', 'start.sh'))) {
@@ -26,7 +26,7 @@ const findPlatformRoot = () => {
}
}
- return path.resolve(app.getAppPath(), '..', '..', '..');
+ return path.resolve(app.getAppPath(), '..', '..', '..', '..', '..');
};
const platformRoot = findPlatformRoot();
@@ -64,7 +64,8 @@ const probeEndpoint = (url) => {
const client = url.startsWith('https') ? https : http;
const request = client.get(url, { timeout: 3000 }, (response) => {
response.resume();
- resolve({ ok: true, status: response.statusCode });
+ const status = response.statusCode || 0;
+ resolve({ ok: status >= 200 && status < 300, status });
});
request.on('timeout', () => {
@@ -79,18 +80,76 @@ const probeEndpoint = (url) => {
const checkPlatformAvailability = async () => {
const probeUrls = [
- 'http://127.0.0.1:80/scan/health',
- 'http://127.0.0.1:80/focus?url=https://example.com',
+ 'http://127.0.0.1:80/scan/healthz',
+ 'http://127.0.0.1:80/focus/readyz',
+ 'http://127.0.0.1:80/phone_search/healthz',
+ 'http://127.0.0.1:80/datasets/healthz',
];
for (const probeUrl of probeUrls) {
const result = await probeEndpoint(probeUrl);
- if (result.ok) {
- return { ready: true, message: 'Platform services are reachable.' };
+ if (!result.ok) {
+ return {
+ ready: false,
+ message: result.status
+ ? `A platform service is not ready (HTTP ${result.status}).`
+ : 'A platform service is not reachable.',
+ };
}
}
- return { ready: false, message: 'Platform services are not reachable yet.' };
+ return { ready: true, message: 'Platform services are reachable.' };
+};
+
+const envPath = path.join(platformRoot, '.env');
+
+const readIntegrationConfiguration = async () => {
+ try {
+ return await fs.readFile(envPath, 'utf8');
+ } catch (error) {
+ if (error.code === 'ENOENT') return '';
+ throw error;
+ }
+};
+
+const checkIntegrations = async () => {
+ const configured = getConfiguredIntegrations(
+ await readIntegrationConfiguration(),
+ );
+ const [x, twilio, datasets] = await Promise.all([
+ probeEndpoint('http://127.0.0.1:80/focus/readyz'),
+ probeEndpoint('http://127.0.0.1:80/phone_search/healthz'),
+ probeEndpoint('http://127.0.0.1:80/datasets/healthz'),
+ ]);
+ return {
+ platformRootAvailable: existsSync(path.join(platformRoot, 'docker-compose.yml')),
+ integrations: [
+ {
+ id: 'x',
+ name: 'X API',
+ client: 'Tweepy',
+ configured: configured.x,
+ reachable: x.ok,
+ statusCode: x.status || null,
+ },
+ {
+ id: 'twilio',
+ name: 'Twilio Lookup',
+ client: 'Twilio SDK',
+ configured: configured.twilio,
+ reachable: twilio.ok,
+ statusCode: twilio.status || null,
+ },
+ {
+ id: 'datasets',
+ name: 'Local datasets',
+ client: 'SQLite',
+ configured: true,
+ reachable: datasets.ok,
+ statusCode: datasets.status || null,
+ },
+ ],
+ };
};
const runCommand = (command, args, cwd) => {
@@ -198,11 +257,85 @@ ipcMain.handle('platform:ensure', async () => {
return ensurePlatformRunning();
});
+ipcMain.handle('integrations:status', async () => {
+ return checkIntegrations();
+});
+
+ipcMain.handle('integrations:save', async (_event, credentials) => {
+ if (!existsSync(path.join(platformRoot, 'docker-compose.yml'))) {
+ throw new Error('The platform repository could not be located.');
+ }
+
+ const current = await readIntegrationConfiguration();
+ const updated = updateEnvContent(current, credentials);
+ if (!updated.updatedKeys.length) {
+ return {
+ saved: false,
+ restarted: false,
+ message: 'No new credential values were provided.',
+ status: await checkIntegrations(),
+ };
+ }
+
+ const temporaryPath = `${envPath}.${process.pid}.tmp`;
+ await fs.writeFile(temporaryPath, updated.content, { encoding: 'utf8', mode: 0o600 });
+ await fs.rename(temporaryPath, envPath);
+ await fs.chmod(envPath, 0o600);
+
+ const docker = checkDockerAvailability();
+ if (!docker.available) {
+ return {
+ saved: true,
+ restarted: false,
+ updatedKeys: updated.updatedKeys,
+ message: 'Credentials were saved. Restart the platform stack to apply them.',
+ status: await checkIntegrations(),
+ };
+ }
+
+ try {
+ const services = [];
+ if (updated.updatedKeys.includes('TWEEPY_BEARER_TOKEN')) {
+ services.push('profile_search');
+ }
+ if (
+ updated.updatedKeys.includes('TWILIO_ACCOUNT_SID')
+ || updated.updatedKeys.includes('TWILIO_AUTH_TOKEN')
+ ) {
+ services.push('phone_search');
+ }
+ await runCommand(
+ docker.binary,
+ ['compose', 'up', '-d', '--force-recreate', ...services],
+ platformRoot,
+ );
+ await wait(5000);
+ return {
+ saved: true,
+ restarted: true,
+ updatedKeys: updated.updatedKeys,
+ message: 'Credentials were saved and the API services were restarted.',
+ status: await checkIntegrations(),
+ };
+ } catch (error) {
+ return {
+ saved: true,
+ restarted: false,
+ updatedKeys: updated.updatedKeys,
+ message: `Credentials were saved, but the services could not restart: ${error.message}`,
+ status: await checkIntegrations(),
+ };
+ }
+});
+
const createWindow = () => {
// Create the browser window.
const mainWindow = new BrowserWindow({
- width: 1000,
- height: 760,
+ width: 1320,
+ height: 860,
+ minWidth: 980,
+ minHeight: 680,
+ backgroundColor: '#09111f',
webPreferences: {
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
sandbox: false,
@@ -212,8 +345,9 @@ const createWindow = () => {
// and load the index.html of the app.
mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
- // Open the DevTools.
- mainWindow.webContents.openDevTools();
+ if (!app.isPackaged && process.env.WHOISIT_DEVTOOLS === '1') {
+ mainWindow.webContents.openDevTools();
+ }
};
// This method will be called when Electron has finished
diff --git a/src/preload.js b/src/preload.js
index f9cadd0..ecaeb0c 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -3,4 +3,6 @@ const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
checkPlatform: () => ipcRenderer.invoke('platform:check'),
ensurePlatform: () => ipcRenderer.invoke('platform:ensure'),
+ getIntegrations: () => ipcRenderer.invoke('integrations:status'),
+ saveIntegrations: (credentials) => ipcRenderer.invoke('integrations:save', credentials),
});
diff --git a/src/searchMode.cjs b/src/searchMode.cjs
new file mode 100644
index 0000000..d561b38
--- /dev/null
+++ b/src/searchMode.cjs
@@ -0,0 +1,32 @@
+const SEARCH_TYPES = new Set(['auto', 'profile', 'phone']);
+const SOURCE_SCOPES = new Set(['all', 'live', 'datasets']);
+
+function looksLikePhone(value) {
+ const query = String(value ?? '').trim();
+ const digits = query.replace(/\D/g, '');
+ if (digits.length < 7 || digits.length > 15) return false;
+ return query.startsWith('+') || /[()\s-]/.test(query);
+}
+
+function resolveSearchType(value, selectedType = 'auto') {
+ if (!SEARCH_TYPES.has(selectedType)) throw new Error('Unknown search type.');
+ if (selectedType !== 'auto') return selectedType;
+ return looksLikePhone(value) ? 'phone' : 'profile';
+}
+
+function normalizeSearchQuery(value, searchType) {
+ const query = String(value ?? '').trim();
+ return searchType === 'profile' ? query.replace(/^@/, '') : query;
+}
+
+function sourceEnabled(scope, source) {
+ if (!SOURCE_SCOPES.has(scope)) throw new Error('Unknown source filter.');
+ return scope === 'all' || scope === source;
+}
+
+module.exports = {
+ looksLikePhone,
+ normalizeSearchQuery,
+ resolveSearchType,
+ sourceEnabled,
+};
diff --git a/tests/dataImport.test.cjs b/tests/dataImport.test.cjs
new file mode 100644
index 0000000..282eb76
--- /dev/null
+++ b/tests/dataImport.test.cjs
@@ -0,0 +1,46 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ getColumns,
+ parseDatasetContent,
+ suggestMapping,
+} = require('../src/dataImport.cjs');
+
+test('parses quoted CSV fields and embedded newlines', () => {
+ const rows = parseDatasetContent(
+ 'profiles.csv',
+ 'handle,name,bio\nada,"Ada, Countess","first line\nsecond line"\n',
+ );
+ assert.deepEqual(rows, [{
+ handle: 'ada',
+ name: 'Ada, Countess',
+ bio: 'first line\nsecond line',
+ }]);
+});
+
+test('parses JSON arrays, records envelopes, and JSONL', () => {
+ assert.deepEqual(parseDatasetContent('a.json', '[{"username":"ada"}]'), [{ username: 'ada' }]);
+ assert.deepEqual(parseDatasetContent('b.json', '{"records":[{"username":"grace"}]}'), [{ username: 'grace' }]);
+ assert.deepEqual(parseDatasetContent('c.jsonl', '{"phone":"+1"}\n{"phone":"+2"}'), [{ phone: '+1' }, { phone: '+2' }]);
+});
+
+test('discovers columns and suggests canonical mappings', () => {
+ const columns = getColumns([{ handle: 'ada', full_name: 'Ada' }, { network: 'X' }]);
+ const mapping = suggestMapping([
+ { key: 'username' },
+ { key: 'display_name' },
+ { key: 'platform' },
+ ], columns);
+ assert.deepEqual(mapping, {
+ username: 'handle',
+ display_name: 'full_name',
+ platform: 'network',
+ });
+});
+
+test('rejects malformed or empty imports', () => {
+ assert.throws(() => parseDatasetContent('a.csv', 'only_header\n'), /header/);
+ assert.throws(() => parseDatasetContent('a.txt', 'anything'), /CSV, JSON/);
+ assert.throws(() => parseDatasetContent('a.jsonl', '{bad}'), /row 1/);
+});
diff --git a/tests/integrationConfig.test.cjs b/tests/integrationConfig.test.cjs
new file mode 100644
index 0000000..a2a6794
--- /dev/null
+++ b/tests/integrationConfig.test.cjs
@@ -0,0 +1,71 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+
+const {
+ getConfiguredIntegrations,
+ getPlatformRootCandidates,
+ parseEnv,
+ updateEnvContent,
+ validateCredentialUpdates,
+} = require('../src/integrationConfig.cjs');
+
+test('finds candidates deep enough for packaged Electron layouts', () => {
+ const appPath = '/code/platform/whoisit/out/whoisit-linux-x64/resources/app.asar';
+ assert.ok(getPlatformRootCandidates(appPath, path).includes('/code/platform'));
+});
+
+test('detects configured integrations without exposing values', () => {
+ const state = getConfiguredIntegrations(
+ 'TWEEPY_BEARER_TOKEN=real-bearer-token-that-is-long\n'
+ + 'TWILIO_ACCOUNT_SID=AC1234567890123456\n'
+ + 'TWILIO_AUTH_TOKEN=1234567890123456\n',
+ );
+ assert.deepEqual(state, { x: true, twilio: true });
+ assert.equal('TWEEPY_BEARER_TOKEN' in state, false);
+});
+
+test('treats example placeholders as unconfigured', () => {
+ assert.deepEqual(
+ getConfiguredIntegrations(
+ 'TWEEPY_BEARER_TOKEN=your_tweepy_bearer_token\n'
+ + 'TWILIO_ACCOUNT_SID=your_twilio_account_sid\n',
+ ),
+ { x: false, twilio: false },
+ );
+});
+
+test('updates known values while preserving comments and unrelated settings', () => {
+ const result = updateEnvContent(
+ '# Existing configuration\nOTHER=value\nTWEEPY_BEARER_TOKEN=old-value-that-is-long\n',
+ {
+ TWEEPY_BEARER_TOKEN: 'new-value-that-is-long-enough',
+ TWILIO_ACCOUNT_SID: 'AC1234567890123456',
+ },
+ );
+ const parsed = parseEnv(result.content);
+ assert.match(result.content, /# Existing configuration/);
+ assert.equal(parsed.OTHER, 'value');
+ assert.equal(parsed.TWEEPY_BEARER_TOKEN, 'new-value-that-is-long-enough');
+ assert.equal(parsed.TWILIO_ACCOUNT_SID, 'AC1234567890123456');
+ assert.deepEqual(result.updatedKeys, [
+ 'TWEEPY_BEARER_TOKEN',
+ 'TWILIO_ACCOUNT_SID',
+ ]);
+});
+
+test('blank values keep existing credentials and malformed values are rejected', () => {
+ assert.deepEqual(validateCredentialUpdates({ TWEEPY_BEARER_TOKEN: '' }), {});
+ assert.throws(
+ () => validateCredentialUpdates({ TWEEPY_BEARER_TOKEN: 'short' }),
+ /incomplete/,
+ );
+ assert.throws(
+ () => validateCredentialUpdates({ TWILIO_ACCOUNT_SID: 'SK1234567890123456' }),
+ /start with AC/,
+ );
+ assert.throws(
+ () => validateCredentialUpdates({ TWILIO_AUTH_TOKEN: 'valid-token-value\nINJECTED=x' }),
+ /invalid characters/,
+ );
+});
diff --git a/tests/searchMode.test.cjs b/tests/searchMode.test.cjs
new file mode 100644
index 0000000..2beac2a
--- /dev/null
+++ b/tests/searchMode.test.cjs
@@ -0,0 +1,33 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ looksLikePhone,
+ normalizeSearchQuery,
+ resolveSearchType,
+ sourceEnabled,
+} = require('../src/searchMode.cjs');
+
+test('auto mode conservatively detects formatted phone numbers', () => {
+ assert.equal(looksLikePhone('+1 202-555-0101'), true);
+ assert.equal(looksLikePhone('(202) 555-0101'), true);
+ assert.equal(looksLikePhone('2025550101'), false);
+ assert.equal(looksLikePhone('demo_user_2026'), false);
+ assert.equal(resolveSearchType('+12025550101'), 'phone');
+ assert.equal(resolveSearchType('demo_ada_1843'), 'profile');
+});
+
+test('explicit modes override auto detection and normalize profile handles', () => {
+ assert.equal(resolveSearchType('+12025550101', 'profile'), 'profile');
+ assert.equal(resolveSearchType('demo_ada_1843', 'phone'), 'phone');
+ assert.equal(normalizeSearchQuery(' @demo_ada_1843 ', 'profile'), 'demo_ada_1843');
+ assert.equal(normalizeSearchQuery(' +1 202-555-0101 ', 'phone'), '+1 202-555-0101');
+});
+
+test('source filters enable only the requested providers', () => {
+ assert.equal(sourceEnabled('all', 'live'), true);
+ assert.equal(sourceEnabled('all', 'datasets'), true);
+ assert.equal(sourceEnabled('live', 'live'), true);
+ assert.equal(sourceEnabled('live', 'datasets'), false);
+ assert.equal(sourceEnabled('datasets', 'live'), false);
+});