feat(desktop): drop parked review flow, add publish management

SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除
manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询,
desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次
发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based
desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与
媒体库;plan A / spec 文档同步口径。
This commit is contained in:
2026-04-20 09:52:48 +08:00
parent b16e9f0bd1
commit a617d39a4a
93 changed files with 5519 additions and 3594 deletions
+207 -7
View File
@@ -32,6 +32,10 @@ import {
} from "./external-window";
import { upsertDesktopAccount } from "./transport/api-client";
import { STANDARD_USER_AGENT } from "./user-agent";
import {
normalizeAccountPlatformUid,
sameAccountPlatformUid,
} from "../shared/account-identity";
interface DetectedAccount {
platformUid: string;
@@ -65,6 +69,11 @@ export interface PublishAccountProfile {
avatarUrl: string | null;
}
export interface PublishAccountLocalInspection {
availability: "missing" | "ready" | "expired";
profile: PublishAccountProfile | null;
}
type ToutiaoMediaInfoResponse = {
data?: {
user?: {
@@ -648,7 +657,11 @@ async function deriveToutiaoFallbackUid(session: Session, displayName: string):
function sanitizeDetectedAccount(account: DetectedAccount): DetectedAccount {
const displayName = normalizeText(account.displayName) ?? "未命名账号";
const platformUid = boundedIdentity("platform", account.platformUid || displayName);
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platformUid);
const platformUid =
normalizedPlatformUid
|| normalizeAccountPlatformUid(boundedIdentity("platform", displayName))
|| displayName;
return {
platformUid,
@@ -772,7 +785,7 @@ async function recoverToutiaoSessionHandle(account: PublishAccountIdentity): Pro
continue;
}
if (expectedUid && detected.platformUid === expectedUid) {
if (expectedUid && sameAccountPlatformUid(detected.platformUid, expectedUid)) {
console.info("[desktop-session] recovered toutiao partition by uid", {
accountId: account.id,
partition,
@@ -805,6 +818,13 @@ async function recoverToutiaoSessionHandle(account: PublishAccountIdentity): Pro
export async function ensurePublishAccountSessionHandle(
account: PublishAccountIdentity,
): Promise<SessionHandle> {
const existing = await findLocalPublishAccountSessionHandle(account);
return existing ?? createSessionHandle(account.id);
}
async function findLocalPublishAccountSessionHandle(
account: PublishAccountIdentity,
): Promise<SessionHandle | null> {
const existing = getSessionHandle(account.id);
if (existing) {
return existing;
@@ -822,19 +842,123 @@ export async function ensurePublishAccountSessionHandle(
}
}
return createSessionHandle(account.id);
return null;
}
function toPublishAccountProfile(detected: DetectedAccount | null): PublishAccountProfile | null {
if (!detected) {
return null;
}
const normalized = sanitizeDetectedAccount(detected);
return {
platformUid: normalized.platformUid,
displayName: normalized.displayName,
avatarUrl: normalized.avatarUrl,
};
}
async function resolvePublishAccountProfileFromHandle(
account: PublishAccountIdentity,
handle: SessionHandle,
): Promise<PublishAccountProfile | null> {
if (account.platform === "toutiaohao") {
return await detectToutiaoFromSession(handle.session);
}
const definition = publishBindingDefinitions[account.platform];
if (!definition) {
return null;
}
const detected = await definition.detect({
session: handle.session,
webContents: undefined as unknown as WebContents,
}).catch(() => null);
return toPublishAccountProfile(detected);
}
export async function inspectPublishAccountLocalSession(
account: PublishAccountIdentity,
): Promise<PublishAccountLocalInspection> {
const handle = await findLocalPublishAccountSessionHandle(account);
if (!handle) {
return {
availability: "missing",
profile: null,
};
}
const profile = await resolvePublishAccountProfileFromHandle(account, handle);
if (!profile) {
const consoleReady = await verifyPublishAccountConsoleAccess(account, handle).catch(() => false);
if (consoleReady) {
return {
availability: "ready",
profile: {
platformUid: normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
displayName: account.displayName,
avatarUrl: null,
},
};
}
if (account.platform === "toutiaohao" && await hasToutiaoAuthCookies(handle.session)) {
return {
availability: "ready",
profile: {
platformUid: normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
displayName: account.displayName,
avatarUrl: null,
},
};
}
return {
availability: "expired",
profile: null,
};
}
return {
availability: "ready",
profile,
};
}
export async function resolvePublishAccountProfile(
account: PublishAccountIdentity,
): Promise<PublishAccountProfile | null> {
const handle = await ensurePublishAccountSessionHandle(account);
return await resolvePublishAccountProfileFromHandle(account, handle);
}
if (account.platform === "toutiaohao") {
return await detectToutiaoFromSession(handle.session);
async function finalizeDetectedAccountForBind(
definition: PublishPlatformBindingDefinition,
context: DetectContext,
detected: DetectedAccount,
): Promise<DetectedAccount> {
if (detected.avatarUrl) {
return detected;
}
return null;
let resolved = detected;
for (let attempt = 0; attempt < 3; attempt += 1) {
await sleep(400 + attempt * 250);
const retried = await definition.detect(context).catch(() => null);
if (!retried) {
continue;
}
const sanitized = sanitizeDetectedAccount(retried);
resolved = sanitized;
if (sanitized.avatarUrl) {
break;
}
}
return resolved;
}
function isToutiaoLoginURL(url: string): boolean {
@@ -908,6 +1032,69 @@ async function verifyToutiaoConsoleAccess(window: BrowserWindow, session: Sessio
return extractToutiaoAccount(sessionResponse) !== null;
}
function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean {
if (!currentURL || !loginURL) {
return false;
}
if (currentURL.startsWith(loginURL)) {
return true;
}
try {
const current = new URL(currentURL);
const login = new URL(loginURL);
if (current.origin !== login.origin) {
return false;
}
const currentPath = current.pathname.replace(/\/+$/, "");
const loginPath = login.pathname.replace(/\/+$/, "");
return currentPath === loginPath || currentPath.startsWith(`${loginPath}/`);
} catch {
return false;
}
}
async function verifyPublishAccountConsoleAccess(
account: PublishAccountIdentity,
handle: SessionHandle,
): Promise<boolean> {
const definition = publishBindingDefinitions[account.platform];
if (!definition) {
return false;
}
const window = createBoundWindow(
`${definition.label} 静默校验`,
definition.consoleUrl,
handle.session,
{ show: false },
);
try {
if (account.platform === "toutiaohao") {
return await verifyToutiaoConsoleAccess(window, handle.session);
}
await waitForWindowNavigationSettled(window);
if (window.isDestroyed()) {
return false;
}
const currentURL = window.webContents.getURL();
if (!currentURL || currentURL.startsWith("data:text/html")) {
return false;
}
return !looksLikeLoginRedirect(currentURL, definition.loginUrl);
} finally {
if (!window.isDestroyed()) {
window.destroy();
}
}
}
async function detectBaijiahao({ session }: DetectContext): Promise<DetectedAccount | null> {
const response = await sessionFetchJson<BaijiahaoAppInfoResponse>(
session,
@@ -1274,8 +1461,14 @@ const publishBindingDefinitions: Record<string, PublishPlatformBindingDefinition
},
};
function createBoundWindow(title: string, targetURL: string, sessionHandle: Session): BrowserWindow {
function createBoundWindow(
title: string,
targetURL: string,
sessionHandle: Session,
options: { show?: boolean } = {},
): BrowserWindow {
const window = new BrowserWindow({
show: options.show ?? true,
width: 1320,
height: 900,
minWidth: 1100,
@@ -1425,10 +1618,17 @@ export async function bindPublishAccount(platformId: string): Promise<DesktopAcc
}
}
normalizedDetected = await finalizeDetectedAccountForBind(
definition,
{ session: handle.session, webContents: window.webContents },
normalizedDetected,
);
const account = await upsertDesktopAccount({
platform: definition.id,
platform_uid: normalizedDetected.platformUid,
display_name: normalizedDetected.displayName,
avatar_url: normalizedDetected.avatarUrl,
account_fingerprint: normalizedDetected.platformUid,
health: "live",
verified_at: new Date().toISOString(),
@@ -3,22 +3,20 @@ import type { Session, WebContentsView } from "electron";
export type AdapterTaskPhase = "initial" | "resume";
export type AdapterCompletionStatus = "succeeded" | "failed" | "unknown";
export type AdapterExecutionMode = "session" | "view" | "playwright";
export interface AdapterContext {
taskId: string;
accountId: string;
session: Session;
view: WebContentsView;
view: WebContentsView | null;
signal: AbortSignal;
mode: "auto" | "manual";
phase: AdapterTaskPhase;
reportProgress(stage: string): void;
}
export interface PublishAdapterContext extends AdapterContext {
article: DesktopArticleContent & {
publish_type: "publish" | "draft";
};
article: DesktopArticleContent;
}
export interface AdapterExecutionResult {
@@ -26,15 +24,16 @@ export interface AdapterExecutionResult {
summary: string;
payload?: Record<string, JsonValue>;
error?: Record<string, JsonValue>;
reviewUrl?: string | null;
}
export interface PublishAdapter {
platform: string;
executionMode?: AdapterExecutionMode;
publish(context: PublishAdapterContext, payload: Record<string, unknown>): Promise<AdapterExecutionResult>;
}
export interface MonitorAdapter {
provider: string;
executionMode?: AdapterExecutionMode;
query(context: AdapterContext, payload: Record<string, unknown>): Promise<AdapterExecutionResult>;
}
@@ -1,5 +1,6 @@
import type { DesktopArticleContent } from "@geo/shared-types";
import type { Session, WebContents, WebContentsView } from "electron/main";
import { marked } from "marked";
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "../user-agent";
@@ -32,7 +33,8 @@ export function normalizeRemoteUrl(value?: string | null): string | null {
}
export function normalizeArticleHtml(article: DesktopArticleContent): string {
let next = article.html_content?.trim() ?? "";
const source = article.html_content?.trim() || markdownToHtml(article.markdown_content ?? "");
let next = source.trim();
next = next.replace(/<section\b/gi, "<div").replace(/<\/section>/gi, "</div>");
next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, "");
next = next.replace(/\sstyle="[^"]*"/gi, "");
@@ -41,6 +43,18 @@ export function normalizeArticleHtml(article: DesktopArticleContent): string {
return next.trim();
}
function markdownToHtml(markdown: string): string {
const source = markdown.trim();
if (!source) {
return "";
}
return marked.parse(source, {
async: false,
gfm: true,
breaks: false,
}) as string;
}
export function extractImageSources(html: string): string[] {
const sources = new Set<string>();
for (const match of html.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
@@ -597,11 +597,16 @@ function parseRuntimeState(value: unknown): DoubaoRuntimeState | null {
}
async function loadDoubaoRuntimeState(context: Parameters<MonitorAdapter["query"]>[0]): Promise<DoubaoRuntimeState> {
if (!context.view) {
throw new Error("doubao_view_required");
}
const view = context.view;
context.reportProgress("doubao.bootstrap_view");
await ensureViewLoaded(context.view, DOUBAO_REFERER, context.signal);
await ensureViewLoaded(view, DOUBAO_REFERER, context.signal);
context.reportProgress("doubao.read_runtime_state");
const state = await context.view.webContents.executeJavaScript(
const state = await view.webContents.executeJavaScript(
`(() => {
try {
const safeParse = (value) => {
@@ -711,7 +716,19 @@ function buildAdapterError(
export const doubaoAdapter: MonitorAdapter = {
provider: "doubao",
executionMode: "view",
async query(context, payload) {
if (!context.view) {
return {
status: "failed",
summary: "豆包监测缺少页面执行上下文。",
error: buildAdapterError(
"doubao_view_required",
"doubao_view_required",
),
};
}
const questionText = extractQuestionText(payload);
const runtimeState = await loadDoubaoRuntimeState(context);
const requestBody = buildDoubaoRequestBody(questionText, runtimeState);
+40 -251
View File
@@ -1,3 +1,6 @@
import {
publishToutiaoArticle,
} from "../../../../../packages/publisher-platforms/src/toutiao";
import type { JsonValue } from "@geo/shared-types";
import {
@@ -8,289 +11,75 @@ import {
} from "./common";
import type { PublishAdapter } from "./base";
type ToutiaoMediaInfoResponse = {
data?: {
user?: {
id_str?: string;
screen_name?: string;
https_avatar_url?: string;
};
media?: {
has_third_party_ad_permission?: boolean;
has_toutiao_ad_permission?: boolean;
};
};
code?: number;
message?: string;
};
type ToutiaoUploadPictureResponse = {
url?: string;
web_uri?: string;
origin_web_uri?: string;
rigin_web_uri?: string;
width?: number;
height?: number;
};
type ToutiaoSpiceImageResponse = {
data?: {
image_url?: string;
image_uri?: string;
image_width?: number;
image_height?: number;
};
};
type ToutiaoPublishResponse = {
code?: number;
message?: string;
data?: {
pgc_id?: string | number;
};
};
async function fetchMediaInfo(context: Parameters<PublishAdapter["publish"]>[0]) {
const response = await sessionFetchJson<ToutiaoMediaInfoResponse>(
context.session,
"https://mp.toutiao.com/mp/agw/media/get_media_info",
{
headers: {
accept: "application/json, text/plain, */*",
},
},
).catch(() => null);
return response?.data ?? null;
}
async function uploadCover(
context: Parameters<PublishAdapter["publish"]>[0],
sourceUrl: string,
) {
const blob = await fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
if (context.article.publish_type === "publish") {
const form = new FormData();
form.append("upfile", blob, "cover.png");
const uploaded = await sessionFetchJson<ToutiaoUploadPictureResponse>(
context.session,
"https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture",
{
method: "POST",
body: form,
},
).catch(() => null);
if (!uploaded?.url || !uploaded.web_uri) {
return null;
}
return {
id: 0,
url: uploaded.url,
uri: uploaded.web_uri,
origin_uri: uploaded.origin_web_uri ?? uploaded.rigin_web_uri ?? "",
thumb_width: uploaded.width ?? 0,
thumb_height: uploaded.height ?? 0,
};
}
const form = new FormData();
form.append("image", blob, "cover.png");
const first = await sessionFetchJson<ToutiaoSpiceImageResponse>(
context.session,
"https://mp.toutiao.com/spice/image?device_platform=web",
{
method: "POST",
body: form,
},
).catch(() => null);
const imageUrl = first?.data?.image_url;
if (!imageUrl) {
return null;
}
const second = new FormData();
second.append("imageUrl", imageUrl);
const final = await sessionFetchJson<ToutiaoSpiceImageResponse>(
context.session,
"https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1",
{
method: "POST",
body: second,
},
).catch(() => null);
if (!final?.data?.image_url || !first.data?.image_uri) {
return null;
}
return {
id: "",
url: final.data.image_url,
uri: first.data.image_uri,
ic_uri: "",
thumb_width: first.data.image_width ?? 0,
thumb_height: first.data.image_height ?? 0,
extra: {
from_content_uri: "",
from_content: "0",
},
};
}
async function uploadContentImage(
context: Parameters<PublishAdapter["publish"]>[0],
sourceUrl: string,
): Promise<string | null> {
const blob = await fetchImageBlob(sourceUrl);
if (!blob) {
return null;
}
const form = new FormData();
form.append("image", blob, "image.png");
const uploaded = await sessionFetchJson<ToutiaoSpiceImageResponse>(
context.session,
"https://mp.toutiao.com/spice/image?device_platform=web",
{
method: "POST",
body: form,
},
).catch(() => null);
return uploaded?.data?.image_url ?? null;
}
function buildResultPayload(
articleId: string,
publishType: "publish" | "draft",
mediaName: string,
externalManageUrl: string,
externalArticleUrl: string | null,
): Record<string, JsonValue> {
const manageURL =
publishType === "draft"
? "https://mp.toutiao.com/profile_v4/graphic/publish"
: "https://mp.toutiao.com/profile_v4/index";
return {
platform: "toutiaohao",
media_name: mediaName,
external_article_id: articleId,
external_manage_url: manageURL,
external_article_url:
publishType === "publish" ? `https://www.toutiao.com/article/${articleId}/` : null,
publish_type: publishType,
external_manage_url: externalManageUrl,
external_article_url: externalArticleUrl,
publish_type: "publish",
};
}
export const toutiaoAdapter: PublishAdapter = {
platform: "toutiaohao",
executionMode: "session",
async publish(context) {
context.reportProgress("toutiao.media_info");
const mediaInfo = await fetchMediaInfo(context);
const user = mediaInfo?.user;
if (!user?.id_str || !user.screen_name) {
return {
status: "failed",
summary: "头条号登录态失效,无法执行发布。",
error: {
code: "toutiaohao_not_logged_in",
message: "未检测到头条号登录态",
},
};
}
context.reportProgress("toutiao.normalize_html");
const html = normalizeArticleHtml(context.article);
if (!html) {
return {
status: "failed",
summary: "文章内容为空,无法推送到头条号。",
error: {
code: "article_content_empty",
message: "html_content is empty",
},
};
}
context.reportProgress("toutiao.upload_content_images");
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadContentImage(context, sourceUrl),
);
context.reportProgress("toutiao.upload_cover");
const cover = context.article.cover_asset_url?.trim()
? await uploadCover(context, context.article.cover_asset_url.trim())
: null;
const hasAdPermission = Boolean(
mediaInfo?.media?.has_third_party_ad_permission || mediaInfo?.media?.has_toutiao_ad_permission,
);
const body = new URLSearchParams({
content: processed.html,
title: context.article.title,
mp_editor_stat: JSON.stringify({ code_block: 1 }),
is_refute_rumor: "0",
save: context.article.publish_type === "publish" ? "1" : "0",
timer_status: "0",
draft_form_data: JSON.stringify({ coverType: 1 }),
article_ad_type: hasAdPermission ? "3" : "2",
pgc_feed_covers: JSON.stringify(cover ? [cover] : []),
is_fans_article: "0",
govern_forward: "0",
praise: "0",
disable_praise: "0",
tree_plan_article: "0",
activity_tag: "0",
trends_writing_tag: "0",
claim_exclusive: "0",
info_source: JSON.stringify({
source_type: 5,
source_author_uid: "",
time_format: "",
position: {},
}),
});
context.reportProgress("toutiao.submit");
const response = await sessionFetchJson<ToutiaoPublishResponse>(
context.session,
"https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231",
const result = await publishToutiaoArticle(
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
referer: "https://mp.toutiao.com/profile_v4/graphic/publish",
title: context.article.title,
html,
coverAssetUrl: context.article.cover_asset_url,
publishType: "publish",
},
{
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
fetchImageBlob,
uploadHtmlImages,
reportProgress(stage) {
context.reportProgress(`toutiao.${stage}`);
},
body,
},
);
const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : "";
if (response.code !== 0 || !articleId) {
if (!result.success) {
const summary =
result.code === "toutiaohao_not_logged_in"
? "头条号登录态失效,无法执行发布。"
: result.code === "article_content_empty"
? "文章内容为空,无法推送到头条号。"
: result.message || "头条号发布失败。";
return {
status: "failed",
summary: response.message || "头条号发布失败。",
summary,
error: {
code: "toutiaohao_publish_failed",
message: response.message || "toutiaohao_publish_failed",
code: result.code,
message: result.message,
},
};
}
const payload = buildResultPayload(articleId, context.article.publish_type, user.screen_name);
const draft = context.article.publish_type === "draft";
const payload = buildResultPayload(
result.articleId,
result.mediaName,
result.externalManageUrl,
result.externalArticleUrl,
);
return {
status: "succeeded",
summary: draft ? "头条号草稿已创建,等待人工审核。" : "头条号发布成功。",
payload,
reviewUrl: typeof payload.external_manage_url === "string" ? payload.external_manage_url : null,
summary: "头条号发布成功。",
};
},
};
+43 -9
View File
@@ -8,12 +8,18 @@ import type {
import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types";
import { bindPublishAccount, openPublishAccountConsole } from "./account-binder";
import { openTaskReview, refreshRuntimeAccounts, resolveParkedTask, syncRuntimeSession } from "./runtime-controller";
import { registerRendererDevtoolsProxyTarget } from "./renderer-devtools-proxy";
import {
refreshRuntimeAccounts,
releaseRuntimeSession,
syncRuntimeSession,
unbindRuntimeAccount,
} from "./runtime-controller";
import { createRuntimeSnapshot } from "./runtime-snapshot";
import { initScheduler } from "./scheduler";
import { initSessionRegistry } from "./session-registry";
import { initSingleInstance } from "./single-instance";
import { initTransport } from "./transport/api-client";
import { initTransport, listDesktopPublishTasks, retryDesktopPublishTask } from "./transport/api-client";
import { initTray } from "./tray";
import { STANDARD_USER_AGENT } from "./user-agent";
@@ -41,6 +47,7 @@ process.on("uncaughtException", (error) => {
});
let mainWindow: ElectronBrowserWindow | null = null;
let quitReleaseInFlight = false;
function rendererURL(): string | null {
return process.env.ELECTRON_RENDERER_URL ?? null;
@@ -66,6 +73,7 @@ async function mountRendererView(window: ElectronBrowserWindow): Promise<void> {
});
view.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
registerRendererDevtoolsProxyTarget(view.webContents);
window.contentView.addChildView(view);
syncViewBounds(window, view);
window.on("resize", () => syncViewBounds(window, view));
@@ -121,6 +129,10 @@ function registerBridgeHandlers(): void {
await refreshRuntimeAccounts();
return account;
});
safeHandle("desktop:refresh-runtime-accounts", async () => {
await refreshRuntimeAccounts();
return null;
});
safeHandle(
"desktop:open-publish-account-console",
async (
@@ -131,17 +143,19 @@ function registerBridgeHandlers(): void {
return null;
},
);
safeHandle("desktop:task-open-review", async (_event, taskId: string) => {
await openTaskReview(taskId);
return null;
});
safeHandle(
"desktop:task-resolve-parked",
async (_event, taskId: string, status: "succeeded" | "failed" | "unknown") => {
await resolveParkedTask(taskId, status);
"desktop:unbind-publish-account",
async (_event, accountId: string, syncVersion: number) => {
await unbindRuntimeAccount(accountId, syncVersion);
return null;
},
);
safeHandle("desktop:list-publish-tasks", async (_event, params?: { page?: number; page_size?: number; title?: string }) => {
return listDesktopPublishTasks(params);
});
safeHandle("desktop:retry-publish-task", async (_event, taskId: string) => {
return retryDesktopPublishTask(taskId);
});
ipcMain.handle(
"desktop:runtime-session-sync",
(_event, session: DesktopRuntimeSessionSyncRequest | null) => {
@@ -149,6 +163,10 @@ function registerBridgeHandlers(): void {
return null;
},
);
safeHandle("desktop:runtime-session-release", async (_event, revoke?: boolean) => {
await releaseRuntimeSession({ revoke: Boolean(revoke) });
return null;
});
}
if (!initSingleInstance(() => {
@@ -191,3 +209,19 @@ app.on("window-all-closed", () => {
app.quit();
}
});
app.on("before-quit", (event) => {
if (quitReleaseInFlight) {
return;
}
quitReleaseInFlight = true;
event.preventDefault();
void Promise.race([
releaseRuntimeSession(),
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
]).finally(() => {
app.quit();
});
});
@@ -6,7 +6,7 @@ interface LeaseSnapshot {
leaseExpiresAt: number | null;
lastExtendedAt: number | null;
lastReleasedAt: number | null;
lastOutcome: "leased" | "extended" | "parked" | "completed" | "failed" | "cleared" | null;
lastOutcome: "leased" | "extended" | "completed" | "failed" | "cleared" | null;
}
interface ActiveLeaseState {
@@ -0,0 +1,74 @@
import type { WebContents } from "electron/main";
interface RendererProxyRequest {
url: string;
method: string;
headers?: Record<string, string>;
body?: string;
}
interface RendererProxyResponse {
ok: boolean;
status: number;
statusText: string;
headers: Record<string, string>;
bodyText: string;
error?: string;
}
let rendererWebContents: WebContents | null = null;
export function registerRendererDevtoolsProxyTarget(webContents: WebContents): void {
rendererWebContents = webContents;
webContents.once("destroyed", () => {
if (rendererWebContents === webContents) {
rendererWebContents = null;
}
});
}
export function canUseRendererDevtoolsProxy(): boolean {
return Boolean(process.env.ELECTRON_RENDERER_URL && rendererWebContents && !rendererWebContents.isDestroyed());
}
export async function rendererDevtoolsFetch(
request: RendererProxyRequest,
): Promise<RendererProxyResponse> {
if (!rendererWebContents || rendererWebContents.isDestroyed()) {
throw new Error("renderer_devtools_proxy_unavailable");
}
const script = `(async () => {
try {
const response = await fetch(${JSON.stringify(request.url)}, {
method: ${JSON.stringify(request.method)},
headers: ${JSON.stringify(request.headers ?? {})},
body: ${JSON.stringify(request.body ?? null)},
});
const bodyText = await response.text();
return JSON.stringify({
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
bodyText,
});
} catch (error) {
return JSON.stringify({
ok: false,
status: 0,
statusText: "",
headers: {},
bodyText: "",
error: String(error && (error.message || error)),
});
}
})()`;
const serialized = await rendererWebContents.executeJavaScript(script, true);
if (typeof serialized !== "string" || !serialized) {
throw new Error("renderer_devtools_proxy_empty_response");
}
return JSON.parse(serialized) as RendererProxyResponse;
}
+184 -431
View File
@@ -17,7 +17,16 @@ import {
type MonitorAdapter,
type PublishAdapter,
} from "./adapters";
import { ensurePublishAccountSessionHandle, resolvePublishAccountProfile, type PublishAccountProfile } from "./account-binder";
import {
ensurePublishAccountSessionHandle,
inspectPublishAccountLocalSession,
type PublishAccountProfile,
} from "./account-binder";
import {
buildAccountIdentityKey,
normalizeAccountPlatformUid,
sameAccountPlatformUid,
} from "../shared/account-identity";
import { acquireHotView } from "./view-pool";
import { getLeaseManagerSnapshot, noteLeaseExtended, noteLeaseReleased, setActiveLease } from "./lease-manager";
import {
@@ -34,11 +43,11 @@ import {
setSchedulerPhase,
setSchedulerQueueDepth,
} from "./scheduler";
import { createSessionHandle, getSessionHandle } from "./session-registry";
import { openReviewWindow } from "./review-window";
import { clearSessionHandle, createSessionHandle, getSessionHandle } from "./session-registry";
import {
completeDesktopTask,
configureTransport,
deleteDesktopAccount,
extendDesktopTask,
getDesktopArticleContent,
heartbeatDesktopClient,
@@ -46,9 +55,11 @@ import {
listDesktopAccounts,
noteTransportHeartbeat,
noteTransportPull,
parkDesktopTask,
offlineDesktopClient,
revokeDesktopClient,
setTransportAuthState,
setTransportSseState,
upsertDesktopAccount,
} from "./transport/api-client";
import { SseClient, SseClientError } from "./transport/sse-client";
@@ -56,17 +67,14 @@ type RuntimeTone = "info" | "success" | "warn" | "danger";
type RuntimeTaskStatus =
| "queued"
| "in_progress"
| "waiting_user"
| "succeeded"
| "failed"
| "unknown"
| "aborted";
type RuntimeTaskMode = "auto" | "manual";
type RuntimeTaskRouting = "rabbitmq-primary" | "db-recovery";
const heartbeatIntervalMs = 25_000;
const pullIntervalMs = 60_000;
const accountsSyncIntervalMs = 90_000;
const leaseExtendIntervalMs = 60_000;
const maxActivityItems = 60;
@@ -80,7 +88,6 @@ interface RuntimeTaskRecord {
accountName: string;
clientId: string;
status: RuntimeTaskStatus;
mode: RuntimeTaskMode;
routing: RuntimeTaskRouting;
leaseExpiresAt: number | null;
updatedAt: number;
@@ -140,7 +147,6 @@ interface RuntimeState {
leaseInFlight: boolean;
heartbeatTimer: ReturnType<typeof setInterval> | null;
pullTimer: ReturnType<typeof setInterval> | null;
accountsTimer: ReturnType<typeof setInterval> | null;
sseClient: SseClient | null;
lastHeartbeatAt: number;
lastHeartbeatStatus: "idle" | "success" | "failed";
@@ -168,7 +174,6 @@ const state: RuntimeState = {
leaseInFlight: false,
heartbeatTimer: null,
pullTimer: null,
accountsTimer: null,
sseClient: null,
lastHeartbeatAt: 0,
lastHeartbeatStatus: "idle",
@@ -213,6 +218,33 @@ export function syncRuntimeSession(next: DesktopRuntimeSessionSyncRequest | null
}
}
export async function releaseRuntimeSession(options: { revoke?: boolean } = {}): Promise<void> {
const currentSession = state.session;
const shouldReleaseRemote = canRunLive(currentSession);
if (shouldReleaseRemote) {
try {
if (options.revoke) {
await revokeDesktopClient();
} else {
await offlineDesktopClient();
}
} catch (error) {
console.warn("[desktop-runtime] release runtime session failed", {
revoke: Boolean(options.revoke),
message: errorMessage(error),
});
}
}
stopRuntime();
clearRuntimeState();
configureTransport(null);
setTransportAuthState("pending");
state.session = null;
state.client = null;
}
export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot {
return {
session: state.session ? { ...state.session } : null,
@@ -238,103 +270,6 @@ export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot {
};
}
export async function openTaskReview(taskId: string): Promise<void> {
const task = state.tasks.get(taskId);
if (!task) {
throw new Error("desktop_task_not_found");
}
if (task.kind !== "publish") {
throw new Error("desktop_task_review_only_for_publish");
}
const reviewURL = resolveTaskReviewUrl(task);
if (!reviewURL) {
throw new Error("desktop_task_review_url_missing");
}
const sessionHandle = createSessionHandle(task.accountId || task.id);
await openReviewWindow({
taskId: task.id,
title: `${task.title} · 审核`,
url: reviewURL,
session: sessionHandle.session,
});
recordActivity("info", "Review Window Opened", `${task.title} 已打开审核页。`);
}
export async function resolveParkedTask(
taskId: string,
status: "succeeded" | "failed" | "unknown",
): Promise<void> {
const existing = state.tasks.get(taskId);
if (!existing) {
throw new Error("desktop_task_not_found");
}
if (existing.status !== "waiting_user") {
throw new Error("desktop_task_not_waiting_user");
}
if (!canRunLive(state.session)) {
throw new Error("desktop_runtime_not_authenticated");
}
if (state.currentTaskId && state.currentTaskId !== taskId) {
throw new Error("desktop_runtime_busy");
}
state.currentTaskId = taskId;
setSchedulerCurrentTask(taskId);
setSchedulerPhase("running");
try {
const leased = await leaseDesktopTask({ task_id: taskId }, { fromParked: true });
if (!leased.task || !leased.lease_token) {
throw new Error("desktop_task_resume_lease_missing");
}
setActiveLease({
taskId,
attemptId: leased.attempt_id ?? null,
leaseExpiresAt: leased.lease_expires_at ?? leased.task.lease_expires_at ?? null,
});
const resumed = upsertTaskFromInfo(leased.task, {
routing: existing.routing,
mode: existing.mode,
attemptId: leased.attempt_id ?? null,
leaseToken: leased.lease_token,
summary: "已从 parked 重新领取租约,准备回写人工审核结果。",
});
const resolutionPayload = buildManualResolutionPayload(resumed, status);
const completed = await completeDesktopTask(taskId, {
lease_token: leased.lease_token,
status,
payload: resolutionPayload.payload,
error: resolutionPayload.error,
});
noteLeaseReleased(status === "failed" ? "failed" : "completed");
upsertTaskFromInfo(completed, {
routing: existing.routing,
mode: existing.mode,
summary: resolutionPayload.summary,
attemptId: null,
leaseToken: null,
});
recordActivity(
status === "failed" ? "danger" : status === "unknown" ? "warn" : "success",
"Parked Task Resolved",
`${existing.title} 已按人工审核结果回写为 ${status}`,
);
} finally {
state.currentTaskId = null;
setSchedulerCurrentTask(null);
noteSchedulerTaskFinished();
processQueue();
}
}
function startRuntime(): void {
if (!canRunLive(state.session) || state.running) {
return;
@@ -349,7 +284,6 @@ function startRuntime(): void {
connectEventStream();
scheduleHeartbeatLoop();
schedulePullLoop();
scheduleAccountsSyncLoop();
void sendHeartbeat("startup");
void syncAccounts("startup");
@@ -372,10 +306,6 @@ function stopRuntime(): void {
clearInterval(state.pullTimer);
state.pullTimer = null;
}
if (state.accountsTimer) {
clearInterval(state.accountsTimer);
state.accountsTimer = null;
}
if (state.sseClient) {
state.sseClient.stop();
state.sseClient = null;
@@ -434,16 +364,6 @@ function schedulePullLoop(): void {
}, pullIntervalMs);
}
function scheduleAccountsSyncLoop(): void {
if (state.accountsTimer) {
clearInterval(state.accountsTimer);
}
state.accountsTimer = setInterval(() => {
void syncAccounts("timer");
}, accountsSyncIntervalMs);
}
function connectEventStream(): void {
if (!canRunLive(state.session)) {
return;
@@ -499,7 +419,6 @@ function connectEventStream(): void {
sseClient.on("task_available", taskEventHandler);
sseClient.on("task_leased", taskEventHandler);
sseClient.on("task_extended", taskEventHandler);
sseClient.on("task_parked", taskEventHandler);
sseClient.on("task_completed", taskEventHandler);
sseClient.on("task_canceled", taskEventHandler);
sseClient.on("task_reconciled", taskEventHandler);
@@ -541,7 +460,6 @@ function handleTaskEvent(event: DesktopTaskEventMessage): void {
accountName: existing?.accountName ?? "待同步账号",
clientId: event.target_client_id,
status: event.status,
mode: existing?.mode ?? "auto",
routing: existing?.routing ?? "rabbitmq-primary",
leaseExpiresAt: event.status === "in_progress" ? existing?.leaseExpiresAt ?? null : null,
updatedAt: parseTimestamp(event.updated_at) ?? Date.now(),
@@ -575,6 +493,7 @@ async function sendHeartbeat(source: "startup" | "timer"): Promise<void> {
cpu_arch: state.client?.cpu_arch ?? process.arch,
client_version: state.client?.client_version ?? "0.1.0-dev",
channel: state.client?.channel ?? "dev",
account_ids: state.accounts.filter((account) => account.health === "live").map((account) => account.id),
});
state.client = response.client;
@@ -609,52 +528,115 @@ async function sendHeartbeat(source: "startup" | "timer"): Promise<void> {
}
}
async function syncAccounts(source: "startup" | "timer" | "manual"): Promise<void> {
async function syncAccounts(source: "startup" | "manual"): Promise<void> {
if (!canRunLive(state.session)) {
return;
}
try {
const accounts = await listDesktopAccounts();
const currentClientId = state.client?.id ?? state.session?.desktop_client?.id ?? null;
state.lastAccountsSyncAt = Date.now();
state.lastError = null;
noteSchedulerAccountsSync();
setSchedulerError(null);
if (source === "startup") {
recordActivity("info", "Accounts Synced", `已同步 ${state.accounts.length} 个桌面账号。`);
}
state.accountProfiles.clear();
state.accounts = await Promise.all(accounts.map(async (account) => {
await ensurePublishAccountSessionHandle({
const syncedAccounts: Array<DesktopAccountInfo | null> = await Promise.all(accounts.map(async (account) => {
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
const identity = {
id: account.id,
platform: account.platform,
platformUid: normalizedPlatformUid,
displayName: account.display_name,
} as const;
const local = await inspectPublishAccountLocalSession(identity);
if (local.availability === "missing") {
return null;
}
if (local.profile && !sameAccountPlatformUid(local.profile.platformUid, normalizedPlatformUid)) {
return {
...account,
platform_uid: normalizedPlatformUid,
health: "expired" as const,
};
}
let resolvedAccount: DesktopAccountInfo = {
...account,
platform_uid: normalizedPlatformUid,
health: local.availability === "expired" ? ("expired" as const) : ("live" as const),
};
const shouldRelinkClient = Boolean(currentClientId && resolvedAccount.client_id !== currentClientId);
if (shouldRelinkClient) {
try {
const updated = await upsertDesktopAccount({
platform: resolvedAccount.platform,
platform_uid: normalizedPlatformUid,
display_name: local.profile?.displayName ?? resolvedAccount.display_name,
avatar_url: local.profile?.avatarUrl ?? resolvedAccount.avatar_url,
account_fingerprint: resolvedAccount.account_fingerprint ?? normalizedPlatformUid,
health: resolvedAccount.health,
verified_at: new Date().toISOString(),
tags: resolvedAccount.tags,
});
resolvedAccount = {
...updated,
platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid,
health: resolvedAccount.health,
};
} catch (error) {
console.warn("[desktop-runtime] account relink failed", {
accountId: resolvedAccount.id,
platform: resolvedAccount.platform,
platformUid: resolvedAccount.platform_uid,
message: errorMessage(error),
});
}
}
return resolvedAccount;
}));
const duplicateAccounts: DesktopAccountInfo[] = [];
const dedupedAccounts: DesktopAccountInfo[] = [];
const seenAccountKeys = new Set<string>();
for (const account of syncedAccounts) {
if (!account) {
continue;
}
const identityKey = buildAccountIdentityKey({
platform: account.platform,
platformUid: account.platform_uid,
displayName: account.display_name,
});
const profile = await resolvePublishAccountProfile({
id: account.id,
platform: account.platform,
platformUid: account.platform_uid,
displayName: account.display_name,
}).catch(() => null);
if (profile) {
state.accountProfiles.set(account.id, profile);
return {
...account,
display_name: profile.displayName,
platform_uid: profile.platformUid,
};
if (seenAccountKeys.has(identityKey)) {
duplicateAccounts.push(account);
continue;
}
return account;
}));
seenAccountKeys.add(identityKey);
dedupedAccounts.push(account);
}
state.accounts = dedupedAccounts;
if (duplicateAccounts.length > 0) {
void cleanupDuplicateDesktopAccounts(duplicateAccounts);
}
if (source === "startup") {
recordActivity("info", "Accounts Synced", `已同步 ${state.accounts.length} 个桌面账号。`);
}
refreshAccountNames();
void sendHeartbeat("timer");
} catch (error) {
const message = errorMessage(error);
state.lastError = message;
@@ -669,10 +651,46 @@ async function syncAccounts(source: "startup" | "timer" | "manual"): Promise<voi
}
}
async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]): Promise<void> {
for (const account of accounts) {
try {
await deleteDesktopAccount(account.id, account.sync_version);
} catch (error) {
console.warn("[desktop-runtime] duplicate desktop account cleanup failed", {
accountId: account.id,
platform: account.platform,
platformUid: account.platform_uid,
message: errorMessage(error),
});
}
}
}
export async function refreshRuntimeAccounts(): Promise<void> {
await syncAccounts("manual");
}
export async function unbindRuntimeAccount(accountId: string, syncVersion: number): Promise<void> {
const matchedAccount = state.accounts.find((item) => item.id === accountId) ?? null;
await deleteDesktopAccount(accountId, syncVersion);
await clearSessionHandle(accountId);
state.accounts = state.accounts.filter((item) => item.id !== accountId);
state.accountProfiles.delete(accountId);
refreshAccountNames();
if (matchedAccount) {
recordActivity(
"info",
"Account Unbound",
`${matchedAccount.display_name} 已解绑,并清理了当前机器上的本地会话缓存。`,
);
}
await syncAccounts("manual");
}
function enqueueTask(taskId: string, routing: RuntimeTaskRouting): void {
if (!taskId || state.currentTaskId === taskId || state.queuedTaskIds.has(taskId)) {
return;
@@ -787,8 +805,6 @@ async function executeLeasedTask(
}
const task = leased.task;
const payload = normalizeJsonObject(task.payload);
const mode = resolveTaskMode(payload);
state.currentTaskId = task.id;
setSchedulerCurrentTask(task.id);
@@ -802,13 +818,9 @@ async function executeLeasedTask(
const taskRecord = upsertTaskFromInfo(task, {
routing,
mode,
attemptId: leased.attempt_id ?? null,
leaseToken: leased.lease_token,
summary:
mode === "manual" && task.kind === "publish"
? "已领取租约,准备切到 waiting_user parked 状态。"
: `已领取租约,开始执行 ${routing === "rabbitmq-primary" ? "MQ 唤醒" : "pull fallback"} 消费。`,
summary: `已领取租约,开始执行 ${routing === "rabbitmq-primary" ? "MQ 唤醒" : "pull fallback"} 消费。`,
});
recordActivity(
@@ -825,11 +837,6 @@ async function executeLeasedTask(
}, leaseExtendIntervalMs);
try {
if (mode === "manual" && task.kind === "publish") {
await prepareManualPublishReview(taskRecord, abortController.signal);
return;
}
const execution = await executeTaskAdapter(taskRecord, abortController.signal);
const completed = await completeDesktopTask(taskRecord.id, {
lease_token: leased.lease_token,
@@ -895,112 +902,6 @@ async function executeLeasedTask(
}
}
async function prepareManualPublishReview(task: RuntimeTaskRecord, signal: AbortSignal): Promise<void> {
const preparation = await executeTaskAdapter(task, signal);
if (preparation.status === "failed") {
const completed = await completeDesktopTask(task.id, {
lease_token: task.leaseToken as string,
status: "failed",
payload: preparation.payload,
error: preparation.error,
});
noteLeaseReleased("failed");
upsertTaskFromInfo(completed, {
routing: task.routing,
mode: task.mode,
summary: preparation.summary,
attemptId: null,
leaseToken: null,
});
recordActivity("danger", "Manual Review Prepare Failed", `${task.title} 无法进入人工审核态。`);
return;
}
const reviewUrl = preparation.reviewUrl ?? resolveTaskReviewUrl({
...task,
result: preparation.payload ?? task.result,
});
if (!reviewUrl) {
const completed = await completeDesktopTask(task.id, {
lease_token: task.leaseToken as string,
status: "unknown",
payload: preparation.payload,
error: preparation.error ?? {
code: "manual_review_url_missing",
message: "manual publish review url is not available for current platform",
},
});
noteLeaseReleased("completed");
upsertTaskFromInfo(completed, {
routing: task.routing,
mode: task.mode,
summary: "当前平台暂未提供可打开的审核页,任务回写为 unknown,等待后续对账。",
attemptId: null,
leaseToken: null,
});
recordActivity("warn", "Manual Review Unavailable", `${task.title} 暂无可用审核页。`);
return;
}
if (reviewUrl) {
try {
await openTaskReviewWindow(task, reviewUrl);
} catch (error) {
recordActivity("warn", "Review Window Open Failed", errorMessage(error));
}
}
await parkLeasedTask(task, {
summary:
preparation.summary || "任务已进入人工审核态,等待当前客户端操作员确认。",
payload: preparation.payload ?? null,
error: preparation.error ?? null,
});
}
async function parkLeasedTask(
task: RuntimeTaskRecord,
options: {
summary: string;
payload?: Record<string, JsonValue> | null;
error?: Record<string, JsonValue> | null;
},
): Promise<void> {
if (!task.leaseToken) {
return;
}
try {
if (options.payload) {
rememberTaskLocalResult(task.id, options.payload);
}
if (options.error) {
rememberTaskLocalError(task.id, options.error);
}
const parked = await parkDesktopTask(task.id, {
lease_token: task.leaseToken,
reason: "waiting_user",
});
noteLeaseReleased("parked");
upsertTaskFromInfo(parked, {
routing: task.routing,
summary: options.summary,
attemptId: null,
leaseToken: null,
});
recordActivity("warn", "Task Parked", `${task.title} 已进入人工审核态。`);
} catch (error) {
recordActivity("danger", "Task Park Failed", `${task.title} 进入人工审核态失败:${errorMessage(error)}`);
}
}
async function extendActiveLease(taskId: string, leaseToken: string): Promise<void> {
try {
const task = await extendDesktopTask(taskId, { lease_token: leaseToken });
@@ -1035,16 +936,18 @@ async function executeTaskAdapter(
return buildScaffoldResult(task, payload, "publish adapter is not implemented yet");
}
const viewHandle = acquireHotView(task.accountId || task.id);
const viewHandle =
adapter.executionMode === "session" || adapter.executionMode === "playwright"
? null
: acquireHotView(task.accountId || task.id);
const result = await adapter.publish(
{
taskId: task.id,
accountId: task.accountId || task.id,
session: sessionHandle.session,
view: viewHandle.view,
view: viewHandle?.view ?? null,
signal,
mode: task.mode,
phase: "initial",
article: await loadPublishArticle(task),
reportProgress(stage: string) {
@@ -1062,16 +965,18 @@ async function executeTaskAdapter(
return buildScaffoldResult(task, payload, "monitor adapter is not implemented yet");
}
const viewHandle = acquireHotView(task.accountId || task.id);
const viewHandle =
adapter.executionMode === "session" || adapter.executionMode === "playwright"
? null
: acquireHotView(task.accountId || task.id);
const result = await adapter.query(
{
taskId: task.id,
accountId: task.accountId || task.id,
session: sessionHandle.session,
view: viewHandle.view,
view: viewHandle?.view ?? null,
signal,
mode: task.mode,
phase: "initial",
reportProgress(stage: string) {
updateTaskProgress(task.id, `monitor adapter progress: ${stage}`);
@@ -1107,7 +1012,7 @@ function buildScaffoldResult(
async function loadPublishArticle(
task: RuntimeTaskRecord,
): Promise<DesktopArticleContent & { publish_type: "publish" | "draft" }> {
): Promise<DesktopArticleContent> {
const articleId = resolveArticleId(task.payload);
if (articleId === null) {
throw new Error("desktop_publish_article_id_missing");
@@ -1117,7 +1022,6 @@ async function loadPublishArticle(
return {
...article,
title: article.title?.trim() || task.title,
publish_type: task.mode === "manual" ? "draft" : "publish",
};
}
@@ -1147,109 +1051,6 @@ function toPositiveInt(value: JsonValue | null): number | null {
return null;
}
async function openTaskReviewWindow(task: RuntimeTaskRecord, reviewURL: string): Promise<void> {
const sessionHandle = createSessionHandle(task.accountId || task.id);
await openReviewWindow({
taskId: task.id,
title: `${task.title} · 审核`,
url: reviewURL,
session: sessionHandle.session,
});
}
function resolveTaskReviewUrl(task: RuntimeTaskRecord): string | null {
const candidate =
firstString(task.result?.external_manage_url) ??
firstString(task.result?.review_url) ??
firstString(task.payload.external_manage_url) ??
firstString(task.payload.review_url);
if (candidate) {
return candidate;
}
if (task.platform === "toutiaohao") {
return "https://mp.toutiao.com/profile_v4/graphic/publish";
}
return null;
}
function firstString(value: JsonValue | undefined): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function buildManualResolutionPayload(
task: RuntimeTaskRecord,
status: "succeeded" | "failed" | "unknown",
): {
payload: Record<string, JsonValue>;
error?: Record<string, JsonValue>;
summary: string;
} {
const payload: Record<string, JsonValue> = {
...(task.result ?? {}),
manual_resolution: status,
resolved_at: new Date().toISOString(),
resolved_by: "desktop_operator",
};
if (status === "succeeded") {
return {
payload,
summary: "人工审核已确认完成,任务回写为 succeeded。",
};
}
if (status === "unknown") {
return {
payload,
error: {
code: "manual_review_unconfirmed",
message: "operator could not confirm final publish result",
},
summary: "人工审核后仍无法确认最终状态,任务回写为 unknown。",
};
}
return {
payload,
error: {
code: "manual_review_rejected",
message: "operator marked parked task as failed",
},
summary: "人工审核已拒绝该任务,任务回写为 failed。",
};
}
function rememberTaskLocalResult(taskId: string, payload: Record<string, JsonValue>): void {
const existing = state.tasks.get(taskId);
if (!existing) {
return;
}
state.tasks.set(taskId, {
...existing,
result: {
...(existing.result ?? {}),
...payload,
},
});
}
function rememberTaskLocalError(taskId: string, error: Record<string, JsonValue>): void {
const existing = state.tasks.get(taskId);
if (!existing) {
return;
}
state.tasks.set(taskId, {
...existing,
error: {
...(existing.error ?? {}),
...error,
},
});
}
function updateTaskProgress(taskId: string, summary: string): void {
const existing = state.tasks.get(taskId);
if (!existing) {
@@ -1265,7 +1066,6 @@ function upsertTaskFromInfo(
task: DesktopTaskInfo,
options: {
routing?: RuntimeTaskRouting;
mode?: RuntimeTaskMode;
summary?: string;
attemptId?: string | null;
leaseToken?: string | null;
@@ -1273,7 +1073,6 @@ function upsertTaskFromInfo(
): RuntimeTaskRecord {
const existing = state.tasks.get(task.id);
const payload = normalizeJsonObject(task.payload);
const mode = options.mode ?? existing?.mode ?? resolveTaskMode(payload);
const record: RuntimeTaskRecord = {
id: task.id,
jobId: task.job_id,
@@ -1284,18 +1083,13 @@ function upsertTaskFromInfo(
accountName: resolveAccountName(task.target_account_id, existing?.accountName),
clientId: task.target_client_id,
status: task.status,
mode,
routing: options.routing ?? existing?.routing ?? "db-recovery",
leaseExpiresAt: parseTimestamp(task.lease_expires_at) ?? null,
updatedAt: parseTimestamp(task.updated_at) ?? Date.now(),
summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status, mode),
summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status),
payload,
error:
normalizeJsonObjectOrNull(task.error)
?? (task.status === "waiting_user" ? existing?.error ?? null : null),
result:
normalizeJsonObjectOrNull(task.result)
?? (task.status === "waiting_user" ? existing?.result ?? null : null),
error: normalizeJsonObjectOrNull(task.error),
result: normalizeJsonObjectOrNull(task.result),
attemptId:
task.status === "in_progress"
? options.attemptId ?? existing?.attemptId ?? task.active_attempt_id ?? null
@@ -1422,20 +1216,14 @@ function defaultTaskTitle(kind: "publish" | "monitor", platform?: string): strin
return `${kind === "publish" ? "发布任务" : "监控任务"}${platform ? ` · ${platform}` : ""}`;
}
function resolveTaskMode(payload: Record<string, JsonValue>): RuntimeTaskMode {
return payload.mode === "manual" ? "manual" : "auto";
}
function summaryFromTaskStatus(status: RuntimeTaskStatus, mode: RuntimeTaskMode): string {
function summaryFromTaskStatus(status: RuntimeTaskStatus): string {
switch (status) {
case "queued":
return "任务已排队,等待客户端领取租约。";
case "in_progress":
return "任务正在执行,结果回写需要携带有效 lease_token。";
case "waiting_user":
return "任务已 parked,等待原客户端上的人工确认。";
case "succeeded":
return mode === "manual" ? "任务已完成人工审核并成功提交。" : "任务执行成功。";
return "任务执行成功。";
case "failed":
return "任务执行失败。";
case "aborted":
@@ -1453,14 +1241,12 @@ function summaryFromEventType(type: DesktopTaskEventMessage["type"], status: Run
return "服务端已确认租约归属。";
case "task_extended":
return "任务租约已续期。";
case "task_parked":
return "任务已 parked 到 waiting_user。";
case "task_canceled":
return "任务已被取消。";
case "task_reconciled":
return "任务已完成人工对账并回写。";
default:
return summaryFromTaskStatus(status, "auto");
return summaryFromTaskStatus(status);
}
}
@@ -1492,39 +1278,6 @@ function normalizeJsonObjectOrNull(
return { ...value };
}
function normalizeUnknownResult(value: Record<string, unknown>): Record<string, JsonValue> {
const output: Record<string, JsonValue> = {};
for (const [key, item] of Object.entries(value)) {
output[key] = normalizeJsonValue(item);
}
return output;
}
function normalizeJsonValue(value: unknown): JsonValue {
if (
value === null
|| typeof value === "string"
|| typeof value === "number"
|| typeof value === "boolean"
) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => normalizeJsonValue(item));
}
if (typeof value === "object" && value) {
const output: Record<string, JsonValue> = {};
for (const [key, item] of Object.entries(value)) {
output[key] = normalizeJsonValue(item);
}
return output;
}
return String(value);
}
function toStructuredError(error: unknown): Record<string, JsonValue> {
if (error instanceof ApiClientError) {
return {
+10 -285
View File
@@ -11,10 +11,7 @@ import { getSessionHandle, listSessionHandleSnapshots } from "./session-registry
import { getTransportSnapshot } from "./transport/api-client";
import { describeVaultBackend } from "./vault";
import { listHotViews } from "./view-pool";
function minutesAgo(now: number, minutes: number): number {
return now - minutes * 60_000;
}
import { normalizeAccountPlatformUid } from "../shared/account-identity";
function minutesAhead(now: number, minutes: number): number {
return now + minutes * 60_000;
@@ -30,11 +27,7 @@ function parseTimestamp(value: string | null | undefined): number | null {
}
export function createRuntimeSnapshot() {
const controller = getRuntimeControllerSnapshot();
if (!controller.session || controller.session.mode === "preview") {
return createPreviewRuntimeSnapshot();
}
return createLiveRuntimeSnapshot(controller);
return createLiveRuntimeSnapshot(getRuntimeControllerSnapshot());
}
function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeControllerSnapshot>) {
@@ -54,7 +47,7 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
|| now;
const clientOnline = controller.running && controller.lastHeartbeatStatus !== "failed";
const queueDepth = controller.tasks.filter((task) =>
["queued", "in_progress", "waiting_user"].includes(task.status),
["queued", "in_progress"].includes(task.status),
).length;
const clients = [
@@ -72,21 +65,21 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
] as const;
const accounts = controller.accounts.map((account) => {
const profile = controller.accountProfiles[account.id] ?? null;
const sessionHandle = getSessionHandle(account.id);
const isHot = hotViews.some((item) => item.accountId === account.id);
const accountQueueDepth = controller.tasks.filter((task) =>
task.accountId === account.id && ["queued", "in_progress", "waiting_user"].includes(task.status),
task.accountId === account.id && ["queued", "in_progress"].includes(task.status),
).length;
return {
id: account.id,
platform: account.platform,
displayName: profile?.displayName ?? account.display_name,
platformUid: profile?.platformUid ?? account.platform_uid,
avatarUrl: profile?.avatarUrl ?? null,
displayName: account.display_name,
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
avatarUrl: account.avatar_url ?? null,
health: account.health,
tags: account.tags,
syncVersion: account.sync_version,
clientId: account.client_id ?? primaryClientId,
partition: sessionHandle?.partition ?? `persist:acc-${account.id}`,
sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold",
@@ -95,7 +88,7 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
|| parseTimestamp(account.verified_at)
|| now,
queueDepth: accountQueueDepth,
online: clientOnline && account.client_id === primaryClientId,
online: clientOnline,
};
});
@@ -107,7 +100,6 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
}, {});
return {
previewMode: false,
generatedAt: now,
app: {
name: "GEO Rankly Desktop",
@@ -129,7 +121,7 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
accountsBound: accounts.length,
queuedTasks: tasks.filter((item) => item.status === "queued").length,
issuesOpen:
tasks.filter((item) => ["waiting_user", "unknown", "failed"].includes(item.status)).length
tasks.filter((item) => ["unknown", "failed"].includes(item.status)).length
+ accounts.filter((item) => item.health !== "live").length,
healthCounts,
},
@@ -163,270 +155,3 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
},
};
}
function createPreviewRuntimeSnapshot() {
const now = Date.now();
const sessions = listSessionHandleSnapshots();
const hotViews = listHotViews();
const leaseManager = getLeaseManagerSnapshot();
const rateLimiter = getRateLimiterSnapshot();
const clients = [
{
id: "cli-self-macbook",
label: "This device",
deviceName: "LiangXu MacBook Pro",
os: process.platform,
status: "online",
lastSeenAt: minutesAgo(now, 1),
queueDepth: 3,
heartbeat: "healthy",
channel: "stable",
},
{
id: "cli-studio-macmini",
label: "Remote node",
deviceName: "Studio Mac mini",
os: "darwin",
status: "offline",
lastSeenAt: minutesAgo(now, 38),
queueDepth: 1,
heartbeat: "stale",
channel: "stable",
},
] as const;
const accounts = [
{
id: "acc-doubao-core",
platform: "doubao",
displayName: "Doubao Monitor Core",
platformUid: "db-core-01",
avatarUrl: null,
health: "live",
tags: ["AI监控", "核心"],
clientId: clients[0].id,
partition: "persist:acc-doubao-core",
sessionState: hotViews.length > 0 ? "hot" : "warm",
lastSyncAt: minutesAgo(now, 3),
queueDepth: 1,
online: true,
},
{
id: "acc-toutiao-brand",
platform: "toutiaohao",
displayName: "Toutiao Brand Primary",
platformUid: "tt-brand-01",
avatarUrl: null,
health: "live",
tags: ["发布", "品牌"],
clientId: clients[0].id,
partition: "persist:acc-toutiao-brand",
sessionState: "warm",
lastSyncAt: minutesAgo(now, 8),
queueDepth: 2,
online: true,
},
{
id: "acc-toutiao-matrix",
platform: "toutiaohao",
displayName: "Toutiao Matrix B",
platformUid: "tt-matrix-b",
avatarUrl: null,
health: "captcha",
tags: ["发布", "待修复"],
clientId: clients[0].id,
partition: "persist:acc-toutiao-matrix",
sessionState: "cold",
lastSyncAt: minutesAgo(now, 42),
queueDepth: 1,
online: true,
},
{
id: "acc-qwen-research",
platform: "qwen",
displayName: "Qwen Research Backup",
platformUid: "qwen-research-02",
avatarUrl: null,
health: "risk",
tags: ["AI监控", "备用"],
clientId: clients[1].id,
partition: "persist:acc-qwen-research",
sessionState: "cold",
lastSyncAt: minutesAgo(now, 96),
queueDepth: 1,
online: false,
},
] as const;
const tasks = [
{
id: "task-monitor-doubao-001",
jobId: "job-monitor-daily-001",
title: "品牌提及采样 · Doubao",
kind: "monitor",
platform: "doubao",
accountId: accounts[0].id,
accountName: accounts[0].displayName,
clientId: accounts[0].clientId,
status: "in_progress",
mode: "auto",
routing: "rabbitmq-primary",
leaseExpiresAt: leaseManager.startedAt ? minutesAhead(leaseManager.startedAt, 10) : minutesAhead(now, 8),
updatedAt: minutesAgo(now, 2),
summary: "当前由本机执行,租约续期正常。",
},
{
id: "task-publish-toutiao-021",
jobId: "job-launch-q2-021",
title: "新品发布 · 头条号主号",
kind: "publish",
platform: "toutiaohao",
accountId: accounts[1].id,
accountName: accounts[1].displayName,
clientId: accounts[1].clientId,
status: "waiting_user",
mode: "manual",
routing: "rabbitmq-primary",
leaseExpiresAt: null,
updatedAt: minutesAgo(now, 6),
summary: "审核页已就绪,等待桌面端人工确认。",
},
{
id: "task-publish-toutiao-022",
jobId: "job-launch-q2-021",
title: "新品发布 · 矩阵 B",
kind: "publish",
platform: "toutiaohao",
accountId: accounts[2].id,
accountName: accounts[2].displayName,
clientId: accounts[2].clientId,
status: "queued",
mode: "auto",
routing: "rabbitmq-primary",
leaseExpiresAt: null,
updatedAt: minutesAgo(now, 1),
summary: "已入 MQ 队列,等待目标客户端拉取租约。",
},
{
id: "task-monitor-qwen-003",
jobId: "job-monitor-daily-003",
title: "竞品问答补采样 · Qwen",
kind: "monitor",
platform: "qwen",
accountId: accounts[3].id,
accountName: accounts[3].displayName,
clientId: accounts[3].clientId,
status: "unknown",
mode: "auto",
routing: "db-recovery",
leaseExpiresAt: null,
updatedAt: minutesAgo(now, 49),
summary: "原客户端离线,已切到 DB 补偿队列等待 reconcile。",
},
{
id: "task-publish-archive-007",
jobId: "job-content-rollout-007",
title: "专题文章回填 · 归档批次",
kind: "publish",
platform: "toutiaohao",
accountId: accounts[1].id,
accountName: accounts[1].displayName,
clientId: accounts[1].clientId,
status: "succeeded",
mode: "auto",
routing: "rabbitmq-primary",
leaseExpiresAt: null,
updatedAt: minutesAgo(now, 34),
summary: "发布完成,回执已写回服务端。",
},
] as const;
const activity = [
{
id: "evt-01",
severity: "info",
title: "MQ fanout ready",
detail: "desktop.task.event 已完成广播初始化,客户端优先走消息唤醒。",
at: minutesAgo(now, 2),
},
{
id: "evt-02",
severity: "warn",
title: "Captcha risk detected",
detail: "Toutiao Matrix B 在最近一次 resync 中被标记为 captcha。",
at: minutesAgo(now, 11),
},
{
id: "evt-03",
severity: "danger",
title: "Offline recovery armed",
detail: "Qwen Research Backup 目标客户端离线,任务已进入 DB fallback 观察态。",
at: minutesAgo(now, 49),
},
{
id: "evt-04",
severity: "success",
title: "Publish receipt committed",
detail: "归档批次回执已完成 result callbackWeb 端可见成功 URL。",
at: minutesAgo(now, 34),
},
] as const;
const healthCounts = accounts.reduce<Record<string, number>>((acc, item) => {
acc[item.health] = (acc[item.health] ?? 0) + 1;
return acc;
}, {});
return {
previewMode: true,
generatedAt: now,
app: {
name: "GEO Rankly Desktop",
version: app.getVersion(),
channel: process.env.NODE_ENV === "development" ? "dev" : "release",
platform: process.platform,
sessionCount: sessions.length,
hotViewCount: hotViews.length,
},
workspace: {
id: "ws-default",
name: "Default Workspace",
strategy: "rabbitmq-first / db-fallback",
nextSweepAt: minutesAhead(now, 5),
lastFullSyncAt: minutesAgo(now, 4),
},
summary: {
onlineClients: clients.filter((item) => item.status === "online").length,
accountsBound: accounts.length,
queuedTasks: tasks.filter((item) => item.status === "queued").length,
issuesOpen:
tasks.filter((item) => ["waiting_user", "unknown"].includes(item.status)).length
+ accounts.filter((item) => item.health !== "live").length,
healthCounts,
},
clients,
accounts,
tasks,
activity,
subsystems: {
transport: getTransportSnapshot(),
circuitBreaker: getCircuitBreakerState(),
keepAlive: getKeepAlivePlan(),
leaseManager: {
...leaseManager,
activeLeaseId: leaseManager.activeLeaseId ?? tasks[0].id,
},
rateLimiter: {
...rateLimiter,
refreshedAt: rateLimiter.refreshedAt || minutesAgo(now, 7),
bucketCount: rateLimiter.bucketCount || 6,
},
recovery: collectRecoverySnapshot(),
scheduler: getSchedulerState(),
sessions,
hotViews,
vault: describeVaultBackend(),
},
};
}
@@ -69,6 +69,19 @@ function rememberPersistedPartition(accountId: string, partition: string): void
});
}
function forgetPersistedPartition(accountId: string): string | null {
const current = readPersistedPartitions();
const partition = current[accountId] ?? null;
if (!partition) {
return null;
}
const next = { ...current };
delete next[accountId];
writePersistedPartitions(next);
return partition;
}
function partitionFor(accountId: string): string {
return `persist:acc-${accountId}`;
}
@@ -166,6 +179,37 @@ export function forgetSessionHandle(accountId: string): void {
registry.delete(accountId);
}
export async function clearSessionHandle(accountId: string): Promise<void> {
const active = registry.get(accountId);
const persistedPartition = forgetPersistedPartition(accountId);
const partition = active?.partition ?? persistedPartition;
registry.delete(accountId);
if (!partition) {
return;
}
const target = prepareSession(session.fromPartition(partition));
try {
await target.clearStorageData();
} catch (error) {
console.warn("[desktop-session] clearStorageData failed", { accountId, partition, error });
}
try {
await target.clearCache();
} catch (error) {
console.warn("[desktop-session] clearCache failed", { accountId, partition, error });
}
try {
await target.cookies.flushStore();
} catch (error) {
console.warn("[desktop-session] flushStore failed", { accountId, partition, error });
}
}
export function getSessionHandle(accountId: string): SessionHandle | undefined {
return registry.get(accountId);
}
@@ -1,19 +1,25 @@
import { createApiClient, type ApiClient } from "@geo/http-client";
import { ApiClientError, createApiClient, type ApiClient } from "@geo/http-client";
import type {
ApiEnvelope,
ApiErrorEnvelope,
CompleteDesktopTaskRequest,
CreatePublishJobResponse,
DesktopArticleContent,
DesktopAccountInfo,
DesktopClientHeartbeatRequest,
DesktopClientHeartbeatResponse,
DesktopPublishTaskListResponse,
DesktopTaskInfo,
DesktopRuntimeSessionSyncRequest,
ExtendDesktopTaskRequest,
LeaseDesktopTaskRequest,
LeaseDesktopTaskResponse,
ParkDesktopTaskRequest,
ListDesktopPublishTasksParams,
UpsertDesktopAccountRequest,
} from "@geo/shared-types";
import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from "../renderer-devtools-proxy";
type TransportAuthState = "pending" | "registered" | "expired";
type TransportSseState = "idle" | "connecting" | "streaming";
@@ -32,6 +38,7 @@ const transportState = {
baseURL: transportSession.baseURL,
initializedAt: 0,
mode: "rabbitmq-first" as const,
requestDebugMode: "main-process" as "main-process" | "renderer-devtools",
auth: "pending" as TransportAuthState,
sseState: "idle" as TransportSseState,
lastHeartbeatAt: 0,
@@ -57,6 +64,97 @@ function createDesktopClient(baseURL: string): ApiClient {
return client;
}
function currentRequestDebugMode(): "main-process" | "renderer-devtools" {
return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process";
}
function resolveDesktopURL(path: string): string {
return new URL(path, transportSession.baseURL).toString();
}
async function proxyDesktopRequest<T, B = unknown>(
method: "GET" | "POST" | "DELETE",
path: string,
body?: B,
): Promise<T> {
const headers: Record<string, string> = {
Accept: "application/json",
};
let serializedBody: string | undefined;
if (body !== undefined) {
headers["Content-Type"] = "application/json";
serializedBody = JSON.stringify(body);
}
if (transportSession.clientToken) {
headers.Authorization = `Bearer ${transportSession.clientToken}`;
}
const response = await rendererDevtoolsFetch({
url: resolveDesktopURL(path),
method,
headers,
body: serializedBody,
});
if (response.error) {
throw new ApiClientError({ message: response.error });
}
let payload: ApiEnvelope<T> | Partial<ApiErrorEnvelope> | null = null;
if (response.bodyText) {
try {
payload = JSON.parse(response.bodyText) as ApiEnvelope<T> | Partial<ApiErrorEnvelope>;
} catch {
payload = null;
}
}
if (!response.ok) {
const errorPayload = payload as Partial<ApiErrorEnvelope> | null;
throw new ApiClientError({
message: errorPayload?.message ?? `desktop_http_${response.status}`,
code: errorPayload?.code ?? 50000,
status: response.status,
detail: errorPayload?.detail,
requestId: errorPayload?.request_id,
});
}
if (!payload || typeof payload !== "object" || !("data" in payload)) {
throw new ApiClientError({
message: "desktop_invalid_api_envelope",
status: response.status,
});
}
return payload.data as T;
}
async function desktopGet<T>(path: string): Promise<T> {
transportState.requestDebugMode = currentRequestDebugMode();
if (transportState.requestDebugMode === "renderer-devtools") {
return proxyDesktopRequest<T>("GET", path);
}
return getDesktopApiClient().get<T>(path);
}
async function desktopPost<T, B = unknown>(path: string, body?: B): Promise<T> {
transportState.requestDebugMode = currentRequestDebugMode();
if (transportState.requestDebugMode === "renderer-devtools") {
return proxyDesktopRequest<T, B>("POST", path, body);
}
return getDesktopApiClient().post<T, B>(path, body);
}
async function desktopDelete<T>(path: string): Promise<T> {
transportState.requestDebugMode = currentRequestDebugMode();
if (transportState.requestDebugMode === "renderer-devtools") {
return proxyDesktopRequest<T>("DELETE", path);
}
return getDesktopApiClient().remove<T>(path);
}
export function initTransport(): ApiClient {
if (desktopApiClient) {
return desktopApiClient;
@@ -64,6 +162,7 @@ export function initTransport(): ApiClient {
transportState.initializedAt = transportState.initializedAt || Date.now();
transportState.baseURL = transportSession.baseURL;
transportState.requestDebugMode = currentRequestDebugMode();
desktopApiClient = createDesktopClient(transportSession.baseURL);
return desktopApiClient;
}
@@ -75,6 +174,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
clientToken: null,
};
transportState.baseURL = transportSession.baseURL;
transportState.requestDebugMode = currentRequestDebugMode();
transportState.auth = "pending";
desktopApiClient = null;
return null;
@@ -85,6 +185,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
clientToken: session.client_token,
};
transportState.baseURL = transportSession.baseURL;
transportState.requestDebugMode = currentRequestDebugMode();
transportState.auth = session.client_token ? "registered" : "pending";
transportState.initializedAt = Date.now();
desktopApiClient = createDesktopClient(transportSession.baseURL);
@@ -124,38 +225,86 @@ export function getTransportSnapshot() {
export async function heartbeatDesktopClient(
payload: DesktopClientHeartbeatRequest,
): Promise<DesktopClientHeartbeatResponse> {
return getDesktopApiClient().post<DesktopClientHeartbeatResponse, DesktopClientHeartbeatRequest>(
return desktopPost<DesktopClientHeartbeatResponse, DesktopClientHeartbeatRequest>(
"/api/desktop/clients/heartbeat",
payload,
);
}
export async function offlineDesktopClient(): Promise<void> {
await desktopPost<{ server_time: string }, Record<string, never>>(
"/api/desktop/clients/offline",
{},
);
}
export async function revokeDesktopClient(): Promise<void> {
await desktopPost<{ id: string }, Record<string, never>>(
"/api/desktop/clients/revoke",
{},
);
}
export async function listDesktopAccounts(): Promise<DesktopAccountInfo[]> {
return getDesktopApiClient().get<DesktopAccountInfo[]>("/api/desktop/accounts");
return desktopGet<DesktopAccountInfo[]>("/api/desktop/accounts");
}
export async function getDesktopArticleContent(articleId: number): Promise<DesktopArticleContent> {
return getDesktopApiClient().get<DesktopArticleContent>(`/api/desktop/content/articles/${articleId}`);
return desktopGet<DesktopArticleContent>(`/api/desktop/content/articles/${articleId}`);
}
export async function listDesktopPublishTasks(
params: ListDesktopPublishTasksParams = {},
): Promise<DesktopPublishTaskListResponse> {
const search = new URLSearchParams();
if (params.page) {
search.set("page", String(params.page));
}
if (params.page_size) {
search.set("page_size", String(params.page_size));
}
if (params.title?.trim()) {
search.set("title", params.title.trim());
}
const query = search.toString();
const suffix = query ? `?${query}` : "";
return desktopGet<DesktopPublishTaskListResponse>(`/api/desktop/publish-tasks${suffix}`);
}
export async function retryDesktopPublishTask(taskId: string): Promise<CreatePublishJobResponse> {
return desktopPost<CreatePublishJobResponse, Record<string, never>>(
`/api/desktop/publish-tasks/${encodeURIComponent(taskId)}/retry`,
{},
);
}
export async function upsertDesktopAccount(
payload: UpsertDesktopAccountRequest,
): Promise<DesktopAccountInfo> {
return getDesktopApiClient().post<DesktopAccountInfo, UpsertDesktopAccountRequest>(
return desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>(
"/api/desktop/accounts",
payload,
);
}
export async function deleteDesktopAccount(
desktopId: string,
ifSyncVersion: number,
): Promise<DesktopAccountInfo> {
const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) });
return desktopDelete<DesktopAccountInfo>(
`/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`,
);
}
export async function leaseDesktopTask(
request: LeaseDesktopTaskRequest,
options: { fromParked?: boolean } = {},
): Promise<LeaseDesktopTaskResponse> {
const taskId = request.task_id?.trim();
const path = taskId ? `/api/desktop/tasks/${taskId}/lease` : "/api/desktop/tasks/lease";
const query = options.fromParked ? "?from_parked=true" : "";
return getDesktopApiClient().post<LeaseDesktopTaskResponse, LeaseDesktopTaskRequest>(
`${path}${query}`,
return desktopPost<LeaseDesktopTaskResponse, LeaseDesktopTaskRequest>(
path,
taskId ? { kind: request.kind } : request,
);
}
@@ -164,27 +313,17 @@ export async function extendDesktopTask(
taskId: string,
payload: ExtendDesktopTaskRequest,
): Promise<DesktopTaskInfo> {
return getDesktopApiClient().post<DesktopTaskInfo, ExtendDesktopTaskRequest>(
return desktopPost<DesktopTaskInfo, ExtendDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/extend`,
payload,
);
}
export async function parkDesktopTask(
taskId: string,
payload: ParkDesktopTaskRequest,
): Promise<DesktopTaskInfo> {
return getDesktopApiClient().post<DesktopTaskInfo, ParkDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/park`,
payload,
);
}
export async function completeDesktopTask(
taskId: string,
payload: CompleteDesktopTaskRequest,
): Promise<DesktopTaskInfo> {
return getDesktopApiClient().post<DesktopTaskInfo, CompleteDesktopTaskRequest>(
return desktopPost<DesktopTaskInfo, CompleteDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/result`,
payload,
);
+17 -5
View File
@@ -1,11 +1,23 @@
import { nativeImage } from "electron/common";
import { Menu, Tray } from "electron/main";
import { Menu, Tray, app } from "electron/main";
import type { Tray as ElectronTray } from "electron/main";
let tray: ElectronTray | null = null;
function createFallbackIcon() {
return nativeImage.createEmpty();
const TRAY_ICON_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAAnUlEQVR4nN2UwQmAMAxF360Dee8I" +
"LuNIxT2cpIv0oJcIEqJGMSIG/qX8fn5/msDfqgcKUIEmqHLW3xHsgAmYTzAJ1+2yOURXNI/77qLo" +
"VvzQufX8EchAEmQ5s2LZjUCThwMTg8E3IymG07PSzotFqoqUHcJZ3akWSTctOYST0cT3hMOiCGte" +
"2HcjakCIHGmiltDW+eNrU7t/dNF/txaahq+VoZ+VygAAAABJRU5ErkJggg==";
function createTrayIcon() {
const buffer = Buffer.from(TRAY_ICON_BASE64, "base64");
const icon = nativeImage.createFromBuffer(buffer);
if (icon.isEmpty()) {
return nativeImage.createEmpty();
}
icon.setTemplateImage(true);
return icon;
}
export function initTray(onOpen: () => void): ElectronTray {
@@ -13,13 +25,13 @@ export function initTray(onOpen: () => void): ElectronTray {
return tray;
}
tray = new Tray(createFallbackIcon());
tray = new Tray(createTrayIcon());
tray.setToolTip("GEO Rankly Desktop");
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: "Open", click: () => onOpen() },
{ type: "separator" },
{ role: "quit", label: "Quit" },
{ label: "Quit", click: () => app.quit() },
]),
);
tray.on("click", () => onOpen());