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:
@@ -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);
|
||||
|
||||
@@ -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: "头条号发布成功。",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 callback,Web 端可见成功 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,
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user