mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
b03042d4f4
Implements Google Drive sync operations (find, create, update, download, delete) via the Electron main process to work around CORS and COEP restrictions in the renderer. Updates the preload bridge and type definitions to expose new API methods, and adapts the sync logic to prefer main process proxies when available. Improves error handling and ensures prompt UI updates on disconnect. Enables more reliable Google Drive cloud sync across platforms.
417 lines
14 KiB
JavaScript
417 lines
14 KiB
JavaScript
/**
|
|
* Google OAuth Bridge (main process)
|
|
*
|
|
* Renderer fetches to Google's OAuth token endpoint can be blocked by CORS.
|
|
* This bridge proxies Google token exchange/refresh and userinfo via the main process.
|
|
*/
|
|
|
|
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
|
|
const GOOGLE_DRIVE_API = "https://www.googleapis.com/drive/v3";
|
|
const GOOGLE_DRIVE_UPLOAD_API = "https://www.googleapis.com/upload/drive/v3";
|
|
const DEFAULT_SYNC_FILE_NAME = "netcatty-vault.json";
|
|
|
|
const isNonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
|
|
|
|
const describeNetworkError = (err) => {
|
|
const msg = err?.message || String(err);
|
|
const cause = err?.cause;
|
|
const causeCode = cause?.code ? ` (${cause.code})` : "";
|
|
const causeMsg = cause?.message ? `: ${cause.message}` : "";
|
|
return `${msg}${causeCode}${causeMsg}`.trim();
|
|
};
|
|
|
|
const safeJsonParse = (text) => {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const describeGoogleApiError = (status, text) => {
|
|
const data = safeJsonParse(text);
|
|
const msg =
|
|
data?.error?.message ||
|
|
data?.error_description ||
|
|
data?.error ||
|
|
(typeof text === "string" && text.trim().length ? text.trim() : "Unknown error");
|
|
return `${status} - ${String(msg).slice(0, 300)}`;
|
|
};
|
|
|
|
/**
|
|
* @param {Electron.IpcMain} ipcMain
|
|
* @param {import('electron')=} electronModule
|
|
*/
|
|
function registerHandlers(ipcMain, electronModule) {
|
|
const fetchImpl =
|
|
electronModule?.net?.fetch ? electronModule.net.fetch.bind(electronModule.net) : fetch;
|
|
|
|
ipcMain.handle("netcatty:google:oauth:exchange", async (_event, payload) => {
|
|
const clientId = payload?.clientId;
|
|
const clientSecret = payload?.clientSecret;
|
|
const code = payload?.code;
|
|
const codeVerifier = payload?.codeVerifier;
|
|
const redirectUri = payload?.redirectUri;
|
|
|
|
if (!isNonEmptyString(clientId)) throw new Error("Missing Google clientId");
|
|
if (!isNonEmptyString(code)) throw new Error("Missing authorization code");
|
|
if (!isNonEmptyString(codeVerifier)) throw new Error("Missing codeVerifier");
|
|
if (!isNonEmptyString(redirectUri)) throw new Error("Missing redirectUri");
|
|
|
|
const body = new URLSearchParams({
|
|
client_id: clientId,
|
|
code,
|
|
code_verifier: codeVerifier,
|
|
grant_type: "authorization_code",
|
|
redirect_uri: redirectUri,
|
|
});
|
|
if (isNonEmptyString(clientSecret)) {
|
|
body.set("client_secret", clientSecret);
|
|
}
|
|
|
|
let res;
|
|
try {
|
|
res = await fetchImpl(GOOGLE_TOKEN_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Google token exchange network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
|
|
);
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
data = { error: text };
|
|
}
|
|
throw new Error(`Google token exchange failed: ${data.error_description || data.error || res.status}`);
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
throw new Error(`Google token exchange invalid JSON: ${text.slice(0, 200)}`);
|
|
}
|
|
|
|
return {
|
|
accessToken: data.access_token,
|
|
refreshToken: data.refresh_token,
|
|
expiresAt: Date.now() + (data.expires_in || 0) * 1000,
|
|
tokenType: data.token_type,
|
|
scope: data.scope,
|
|
};
|
|
});
|
|
|
|
ipcMain.handle("netcatty:google:oauth:refresh", async (_event, payload) => {
|
|
const clientId = payload?.clientId;
|
|
const clientSecret = payload?.clientSecret;
|
|
const refreshToken = payload?.refreshToken;
|
|
|
|
if (!isNonEmptyString(clientId)) throw new Error("Missing Google clientId");
|
|
if (!isNonEmptyString(refreshToken)) throw new Error("Missing refreshToken");
|
|
|
|
const body = new URLSearchParams({
|
|
client_id: clientId,
|
|
refresh_token: refreshToken,
|
|
grant_type: "refresh_token",
|
|
});
|
|
if (isNonEmptyString(clientSecret)) {
|
|
body.set("client_secret", clientSecret);
|
|
}
|
|
|
|
let res;
|
|
try {
|
|
res = await fetchImpl(GOOGLE_TOKEN_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Google token refresh network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
|
|
);
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
data = { error: text };
|
|
}
|
|
throw new Error(`Google token refresh failed: ${data.error_description || data.error || res.status}`);
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
throw new Error(`Google token refresh invalid JSON: ${text.slice(0, 200)}`);
|
|
}
|
|
|
|
return {
|
|
accessToken: data.access_token,
|
|
refreshToken,
|
|
expiresAt: Date.now() + (data.expires_in || 0) * 1000,
|
|
tokenType: data.token_type,
|
|
scope: data.scope,
|
|
};
|
|
});
|
|
|
|
ipcMain.handle("netcatty:google:oauth:userinfo", async (_event, payload) => {
|
|
const accessToken = payload?.accessToken;
|
|
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
|
|
|
|
let res;
|
|
try {
|
|
res = await fetchImpl(GOOGLE_USERINFO_URL, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Google userinfo network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
|
|
);
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
throw new Error(`Google userinfo failed: ${res.status} - ${text.slice(0, 200)}`);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw new Error(`Google userinfo invalid JSON: ${text.slice(0, 200)}`);
|
|
}
|
|
});
|
|
|
|
// Google Drive API (appDataFolder) - proxied to avoid CORS/COEP issues in renderer
|
|
ipcMain.handle("netcatty:google:drive:findSyncFile", async (_event, payload) => {
|
|
const accessToken = payload?.accessToken;
|
|
const fileName = isNonEmptyString(payload?.fileName) ? payload.fileName : DEFAULT_SYNC_FILE_NAME;
|
|
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
|
|
|
|
const params = new URLSearchParams({
|
|
spaces: "appDataFolder",
|
|
q: `name = '${fileName}'`,
|
|
fields: "files(id, name, modifiedTime)",
|
|
});
|
|
|
|
let res;
|
|
try {
|
|
res = await fetchImpl(`${GOOGLE_DRIVE_API}/files?${params.toString()}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Google Drive network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
|
|
);
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
if (res.status === 403) {
|
|
throw new Error("Google Drive API not enabled. Please enable it in Google Cloud Console.");
|
|
}
|
|
if (res.status === 401) {
|
|
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
|
|
}
|
|
throw new Error(`Google Drive API error: ${describeGoogleApiError(res.status, text)}`);
|
|
}
|
|
|
|
const data = safeJsonParse(text);
|
|
const fileId = data?.files?.[0]?.id || null;
|
|
return { fileId };
|
|
});
|
|
|
|
ipcMain.handle("netcatty:google:drive:createSyncFile", async (_event, payload) => {
|
|
const accessToken = payload?.accessToken;
|
|
const fileName = isNonEmptyString(payload?.fileName) ? payload.fileName : DEFAULT_SYNC_FILE_NAME;
|
|
const syncedFile = payload?.syncedFile;
|
|
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
|
|
if (!syncedFile) throw new Error("Missing syncedFile");
|
|
|
|
const boundary = `----netcatty_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
const metadata = JSON.stringify({
|
|
name: fileName,
|
|
parents: ["appDataFolder"],
|
|
});
|
|
const content = JSON.stringify(syncedFile, null, 2);
|
|
|
|
const body =
|
|
`--${boundary}\r\n` +
|
|
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
|
|
`${metadata}\r\n` +
|
|
`--${boundary}\r\n` +
|
|
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
|
|
`${content}\r\n` +
|
|
`--${boundary}--\r\n`;
|
|
|
|
let res;
|
|
try {
|
|
res = await fetchImpl(`${GOOGLE_DRIVE_UPLOAD_API}/files?uploadType=multipart`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: "application/json",
|
|
"Content-Type": `multipart/related; boundary=${boundary}`,
|
|
},
|
|
body,
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Google Drive upload network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
|
|
);
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
if (res.status === 403) {
|
|
throw new Error("Google Drive API not enabled. Please enable it in Google Cloud Console.");
|
|
}
|
|
if (res.status === 401) {
|
|
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
|
|
}
|
|
throw new Error(`Google Drive upload error: ${describeGoogleApiError(res.status, text)}`);
|
|
}
|
|
|
|
const data = safeJsonParse(text);
|
|
const fileId = data?.id;
|
|
if (!isNonEmptyString(fileId)) {
|
|
throw new Error(`Google Drive upload invalid response: ${text.slice(0, 200)}`);
|
|
}
|
|
|
|
return { fileId };
|
|
});
|
|
|
|
ipcMain.handle("netcatty:google:drive:updateSyncFile", async (_event, payload) => {
|
|
const accessToken = payload?.accessToken;
|
|
const fileId = payload?.fileId;
|
|
const syncedFile = payload?.syncedFile;
|
|
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
|
|
if (!isNonEmptyString(fileId)) throw new Error("Missing fileId");
|
|
if (!syncedFile) throw new Error("Missing syncedFile");
|
|
|
|
let res;
|
|
try {
|
|
res = await fetchImpl(`${GOOGLE_DRIVE_UPLOAD_API}/files/${encodeURIComponent(fileId)}?uploadType=media`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(syncedFile, null, 2),
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Google Drive update network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
|
|
);
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
if (res.status === 403) {
|
|
throw new Error("Google Drive API not enabled. Please enable it in Google Cloud Console.");
|
|
}
|
|
if (res.status === 401) {
|
|
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
|
|
}
|
|
throw new Error(`Google Drive update error: ${describeGoogleApiError(res.status, text)}`);
|
|
}
|
|
|
|
return { ok: true };
|
|
});
|
|
|
|
ipcMain.handle("netcatty:google:drive:downloadSyncFile", async (_event, payload) => {
|
|
const accessToken = payload?.accessToken;
|
|
const fileId = payload?.fileId;
|
|
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
|
|
if (!isNonEmptyString(fileId)) throw new Error("Missing fileId");
|
|
|
|
let res;
|
|
try {
|
|
res = await fetchImpl(`${GOOGLE_DRIVE_API}/files/${encodeURIComponent(fileId)}?alt=media`, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Google Drive download network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
|
|
);
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
if (res.status === 404) return { syncedFile: null };
|
|
if (res.status === 401) {
|
|
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
|
|
}
|
|
throw new Error(`Google Drive download error: ${describeGoogleApiError(res.status, text)}`);
|
|
}
|
|
|
|
const data = safeJsonParse(text);
|
|
if (!data) {
|
|
throw new Error(`Google Drive download invalid JSON: ${text.slice(0, 200)}`);
|
|
}
|
|
|
|
return { syncedFile: data };
|
|
});
|
|
|
|
ipcMain.handle("netcatty:google:drive:deleteSyncFile", async (_event, payload) => {
|
|
const accessToken = payload?.accessToken;
|
|
const fileId = payload?.fileId;
|
|
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
|
|
if (!isNonEmptyString(fileId)) throw new Error("Missing fileId");
|
|
|
|
let res;
|
|
try {
|
|
res = await fetchImpl(`${GOOGLE_DRIVE_API}/files/${encodeURIComponent(fileId)}`, {
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Google Drive delete network error. Check your network/VPN and whether Google services are reachable. (${describeNetworkError(err)})`
|
|
);
|
|
}
|
|
|
|
if (!res.ok && res.status !== 404) {
|
|
const text = await res.text();
|
|
if (res.status === 401) {
|
|
throw new Error("Token expired or invalid. Please reconnect Google Drive.");
|
|
}
|
|
throw new Error(`Google Drive delete error: ${describeGoogleApiError(res.status, text)}`);
|
|
}
|
|
|
|
return { ok: true };
|
|
});
|
|
}
|
|
|
|
module.exports = { registerHandlers };
|