waitspin 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -7
- package/assets/waitspin-mimocode/README.md +169 -0
- package/assets/waitspin-mimocode/mimocode-install.sh +44 -0
- package/assets/waitspin-mimocode/mimocode-runtime.sh +19 -0
- package/assets/waitspin-mimocode/mimocode-status.sh +44 -0
- package/assets/waitspin-mimocode/mimocode-uninstall.sh +44 -0
- package/assets/waitspin-opencode/opencode-statusline.mjs +280 -0
- package/assets/waitspin-opencode/waitspin-opencode.plugin.tsx +267 -0
- package/dist/cli.js +1308 -37
- package/dist/cli.js.map +1 -1
- package/package.json +7 -2
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// WaitSpin OpenCode Statusline Runtime
|
|
4
|
+
// Installed by: waitspin opencode install
|
|
5
|
+
//
|
|
6
|
+
// Called periodically by the OpenCode TUI plugin. Fetches the next sponsored
|
|
7
|
+
// message from the WaitSpin API, manages serve caching, and records
|
|
8
|
+
// impressions after the minimum visible interval.
|
|
9
|
+
//
|
|
10
|
+
// Usage: node opencode-statusline.mjs --state STATE_PATH
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
mkdir,
|
|
14
|
+
readFile,
|
|
15
|
+
rename,
|
|
16
|
+
rm,
|
|
17
|
+
stat,
|
|
18
|
+
writeFile,
|
|
19
|
+
} from "node:fs/promises"
|
|
20
|
+
import os from "node:os"
|
|
21
|
+
import path from "node:path"
|
|
22
|
+
|
|
23
|
+
// ─── Constants ──────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const FETCH_INTERVAL_MS = 15_000
|
|
26
|
+
const FETCH_TIMEOUT_MS = 2_500
|
|
27
|
+
const MAX_ACTIVE_AGE_MS = 60_000
|
|
28
|
+
const LOCK_RETRY_MS = 40
|
|
29
|
+
const LOCK_TIMEOUT_MS = 2_000
|
|
30
|
+
const LOCK_STALE_MS = 10_000
|
|
31
|
+
const DEFAULT_MIN_VISIBLE_MS = 5_000
|
|
32
|
+
const CACHE_KEY = "opencode"
|
|
33
|
+
const MANAGED_STATE_PATH = path.join(
|
|
34
|
+
os.homedir(),
|
|
35
|
+
".waitspin",
|
|
36
|
+
"opencode-install.json",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function argValue(name) {
|
|
42
|
+
for (let index = 2; index < process.argv.length; index += 1) {
|
|
43
|
+
if (process.argv[index] !== name) continue
|
|
44
|
+
const value = process.argv[index + 1]
|
|
45
|
+
return value && !value.startsWith("--") ? value : undefined
|
|
46
|
+
}
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function managedStatePath(value) {
|
|
51
|
+
if (!value) return null
|
|
52
|
+
const resolved = path.resolve(value)
|
|
53
|
+
return resolved === path.resolve(MANAGED_STATE_PATH) ? resolved : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readJson(filePath, fallback) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(await readFile(filePath, "utf8"))
|
|
59
|
+
} catch {
|
|
60
|
+
return fallback
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readInstallState(value) {
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
|
66
|
+
const apiKey = typeof value.api_key === "string" ? value.api_key.trim() : ""
|
|
67
|
+
const installId =
|
|
68
|
+
typeof value.install_id === "string" ? value.install_id.trim() : ""
|
|
69
|
+
const baseUrl =
|
|
70
|
+
typeof value.base_url === "string" ? value.base_url.trim() : ""
|
|
71
|
+
const cachePath =
|
|
72
|
+
typeof value.cache_path === "string" ? value.cache_path.trim() : ""
|
|
73
|
+
if (!apiKey || !installId || !baseUrl || !cachePath) return null
|
|
74
|
+
return {
|
|
75
|
+
...value,
|
|
76
|
+
api_key: apiKey,
|
|
77
|
+
install_id: installId,
|
|
78
|
+
base_url: baseUrl,
|
|
79
|
+
cache_path: cachePath,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function writeJson(filePath, value) {
|
|
84
|
+
const tmp = filePath + "." + process.pid + ".tmp"
|
|
85
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", {
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
mode: 0o600,
|
|
88
|
+
})
|
|
89
|
+
await rename(tmp, filePath)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sleep(ms) {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function acquireCacheLock(cachePath) {
|
|
97
|
+
const lockPath = cachePath + ".lock"
|
|
98
|
+
const startedAt = Date.now()
|
|
99
|
+
|
|
100
|
+
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
101
|
+
try {
|
|
102
|
+
await mkdir(lockPath)
|
|
103
|
+
return async () => {
|
|
104
|
+
await rm(lockPath, { recursive: true, force: true })
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
try {
|
|
108
|
+
const lockStat = await stat(lockPath)
|
|
109
|
+
if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
|
|
110
|
+
await rm(lockPath, { recursive: true, force: true })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Another process may have released the lock between mkdir/stat
|
|
115
|
+
}
|
|
116
|
+
await sleep(LOCK_RETRY_MS)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new Error("Timed out waiting for WaitSpin OpenCode cache lock.")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function withCacheLock(cachePath, callback) {
|
|
124
|
+
const release = await acquireCacheLock(cachePath)
|
|
125
|
+
try {
|
|
126
|
+
return await callback()
|
|
127
|
+
} finally {
|
|
128
|
+
await release()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cleanLine(value) {
|
|
133
|
+
return String(value || "")
|
|
134
|
+
.replace(
|
|
135
|
+
/(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
|
|
136
|
+
" ",
|
|
137
|
+
)
|
|
138
|
+
.replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
|
|
139
|
+
.replace(/\s+/g, " ")
|
|
140
|
+
.trim()
|
|
141
|
+
.slice(0, 120)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseServe(payload) {
|
|
145
|
+
if (!payload || typeof payload !== "object") return null
|
|
146
|
+
const creative = payload.creative
|
|
147
|
+
if (!creative || typeof creative !== "object") return null
|
|
148
|
+
const line = cleanLine(creative.line)
|
|
149
|
+
if (!line) return null
|
|
150
|
+
if (
|
|
151
|
+
typeof payload.serve_id !== "string" ||
|
|
152
|
+
typeof payload.serve_receipt !== "string"
|
|
153
|
+
) {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
serveId: payload.serve_id,
|
|
158
|
+
serveReceipt: payload.serve_receipt,
|
|
159
|
+
line,
|
|
160
|
+
shownAt: Date.now(),
|
|
161
|
+
minVisibleMs:
|
|
162
|
+
typeof payload.min_visible_ms === "number" && payload.min_visible_ms >= DEFAULT_MIN_VISIBLE_MS
|
|
163
|
+
? payload.min_visible_ms
|
|
164
|
+
: DEFAULT_MIN_VISIBLE_MS,
|
|
165
|
+
impressionRecorded: false,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── WaitSpin API Calls ─────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
async function waitspinFetch(url, init) {
|
|
172
|
+
const controller = new AbortController()
|
|
173
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
174
|
+
try {
|
|
175
|
+
return await fetch(url, { ...init, signal: controller.signal })
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timeout)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function fetchNextServe(state, session) {
|
|
182
|
+
const response = await waitspinFetch(state.base_url + "/v1/serve/next", {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: {
|
|
185
|
+
Authorization: "Bearer " + state.api_key,
|
|
186
|
+
"Content-Type": "application/json",
|
|
187
|
+
},
|
|
188
|
+
body: JSON.stringify({ install_id: state.install_id }),
|
|
189
|
+
})
|
|
190
|
+
session.lastFetchAt = Date.now()
|
|
191
|
+
if (response.status === 204) {
|
|
192
|
+
session.activeServe = null
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (!response.ok) return
|
|
196
|
+
const parsed = parseServe(await response.json())
|
|
197
|
+
if (parsed) session.activeServe = parsed
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function recordImpression(state, session) {
|
|
201
|
+
const serve = session.activeServe
|
|
202
|
+
if (!serve || serve.impressionRecorded) return
|
|
203
|
+
const visibleMs = Date.now() - serve.shownAt
|
|
204
|
+
if (visibleMs < serve.minVisibleMs) return
|
|
205
|
+
const response = await waitspinFetch(state.base_url + "/v1/events/impression", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: {
|
|
208
|
+
Authorization: "Bearer " + state.api_key,
|
|
209
|
+
"Content-Type": "application/json",
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
serve_id: serve.serveId,
|
|
213
|
+
serve_receipt: serve.serveReceipt,
|
|
214
|
+
install_id: state.install_id,
|
|
215
|
+
visible_ms: Math.max(visibleMs, serve.minVisibleMs),
|
|
216
|
+
}),
|
|
217
|
+
})
|
|
218
|
+
if (response.ok) serve.impressionRecorded = true
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function pruneSessions(cache) {
|
|
222
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000
|
|
223
|
+
for (const [key, value] of Object.entries(cache.sessions || {})) {
|
|
224
|
+
if ((value.lastSeenAt || 0) < cutoff) delete cache.sessions[key]
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Main ───────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
async function main() {
|
|
231
|
+
const statePath = managedStatePath(argValue("--state"))
|
|
232
|
+
if (!statePath) return
|
|
233
|
+
|
|
234
|
+
const state = readInstallState(await readJson(statePath, null))
|
|
235
|
+
if (!state) return
|
|
236
|
+
|
|
237
|
+
let sponsorLine = ""
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
sponsorLine = await withCacheLock(state.cache_path, async () => {
|
|
241
|
+
const cache = await readJson(state.cache_path, { sessions: {} })
|
|
242
|
+
if (!cache.sessions || typeof cache.sessions !== "object") cache.sessions = {}
|
|
243
|
+
|
|
244
|
+
// OpenCode uses a single session key
|
|
245
|
+
const session = cache.sessions[CACHE_KEY] || {}
|
|
246
|
+
session.lastSeenAt = Date.now()
|
|
247
|
+
cache.sessions[CACHE_KEY] = session
|
|
248
|
+
|
|
249
|
+
// Expire stale active serve
|
|
250
|
+
if (
|
|
251
|
+
session.activeServe &&
|
|
252
|
+
Date.now() - session.activeServe.shownAt > MAX_ACTIVE_AGE_MS
|
|
253
|
+
) {
|
|
254
|
+
session.activeServe = null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await recordImpression(state, session)
|
|
258
|
+
|
|
259
|
+
const shouldFetchNext = !session.activeServe
|
|
260
|
+
? Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS
|
|
261
|
+
: session.activeServe.impressionRecorded &&
|
|
262
|
+
Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS
|
|
263
|
+
|
|
264
|
+
if (shouldFetchNext) {
|
|
265
|
+
await fetchNextServe(state, session)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
pruneSessions(cache)
|
|
269
|
+
await writeJson(state.cache_path, cache)
|
|
270
|
+
return session
|
|
271
|
+
})
|
|
272
|
+
} catch {
|
|
273
|
+
// Statusline rendering must never interrupt the host
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const line = sponsorLine?.activeServe?.line || ""
|
|
277
|
+
if (line) process.stdout.write(line)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
main().catch(() => {})
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// WaitSpin OpenCode TUI Plugin
|
|
2
|
+
// Installed by: waitspin opencode install
|
|
3
|
+
// Slot: app_bottom
|
|
4
|
+
//
|
|
5
|
+
// Displays a WaitSpin sponsored message in the OpenCode TUI app_bottom slot.
|
|
6
|
+
// Polls the WaitSpin API every 15s and records impressions after 5s visible.
|
|
7
|
+
|
|
8
|
+
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
9
|
+
import { execFile } from "node:child_process"
|
|
10
|
+
import { readFile } from "node:fs/promises"
|
|
11
|
+
import { createSignal, Show } from "solid-js"
|
|
12
|
+
|
|
13
|
+
const POLL_INTERVAL_MS = 15_000
|
|
14
|
+
const MIN_VISIBLE_MS = 5_000
|
|
15
|
+
const FETCH_TIMEOUT_MS = 5_000
|
|
16
|
+
|
|
17
|
+
const INSTALL_CONFIG = {
|
|
18
|
+
statePath: "__WAITSPIN_STATE_PATH__",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface WaitSpinConfig {
|
|
22
|
+
baseUrl: string
|
|
23
|
+
apiKey: string
|
|
24
|
+
installId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ActiveServe {
|
|
28
|
+
serveId: string
|
|
29
|
+
line: string
|
|
30
|
+
destinationUrl: string
|
|
31
|
+
serveReceipt: string
|
|
32
|
+
shownAt: number
|
|
33
|
+
minVisibleMs: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const plugin: TuiPlugin = async (api: TuiPluginApi) => {
|
|
37
|
+
const [sponsorLine, setSponsorLine] = createSignal("")
|
|
38
|
+
const [destinationUrl, setDestinationUrl] = createSignal("")
|
|
39
|
+
|
|
40
|
+
let activeServe: ActiveServe | null = null
|
|
41
|
+
let pollTimer: ReturnType<typeof setInterval> | undefined
|
|
42
|
+
let impressionTimer: ReturnType<typeof setTimeout> | undefined
|
|
43
|
+
let isPolling = false
|
|
44
|
+
let cachedConfig: WaitSpinConfig | null | undefined
|
|
45
|
+
|
|
46
|
+
function kvGet(key: string): string {
|
|
47
|
+
try {
|
|
48
|
+
return api.kv.get<string>(key) || ""
|
|
49
|
+
} catch {
|
|
50
|
+
return ""
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readStateConfig(): Promise<WaitSpinConfig | null> {
|
|
55
|
+
if (cachedConfig !== undefined) return cachedConfig
|
|
56
|
+
try {
|
|
57
|
+
const raw = await readFile(INSTALL_CONFIG.statePath, "utf8")
|
|
58
|
+
const state = JSON.parse(raw) as Record<string, unknown>
|
|
59
|
+
const baseUrl = typeof state.base_url === "string" ? state.base_url.trim() : ""
|
|
60
|
+
const apiKey = typeof state.api_key === "string" ? state.api_key.trim() : ""
|
|
61
|
+
const installId = typeof state.install_id === "string" ? state.install_id.trim() : ""
|
|
62
|
+
cachedConfig = baseUrl && apiKey && installId
|
|
63
|
+
? { baseUrl, apiKey, installId }
|
|
64
|
+
: null
|
|
65
|
+
} catch {
|
|
66
|
+
cachedConfig = null
|
|
67
|
+
}
|
|
68
|
+
return cachedConfig
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolveConfig(): Promise<WaitSpinConfig | null> {
|
|
72
|
+
const apiKey = kvGet("waitspin_api_key")
|
|
73
|
+
const installId = kvGet("waitspin_install_id")
|
|
74
|
+
const baseUrl = kvGet("waitspin_base_url") || "https://api.waitspin.com"
|
|
75
|
+
if (apiKey && installId) return { baseUrl, apiKey, installId }
|
|
76
|
+
return readStateConfig()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isSafeUrl(url: string): boolean {
|
|
80
|
+
try {
|
|
81
|
+
const rawHost = rawHostname(url)
|
|
82
|
+
if (/^(?:\d+|0x[0-9a-f]+|0[0-7]+)$/i.test(rawHost)) return false
|
|
83
|
+
const parsed = new URL(url)
|
|
84
|
+
if (parsed.protocol !== "https:") return false
|
|
85
|
+
if (parsed.username || parsed.password) return false
|
|
86
|
+
const host = parsed.hostname.toLowerCase()
|
|
87
|
+
if (["localhost", "::1", "0.0.0.0"].includes(host)) return false
|
|
88
|
+
if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/.test(host)) return false
|
|
89
|
+
if (host.startsWith("::ffff:")) return false
|
|
90
|
+
if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return false
|
|
91
|
+
return true
|
|
92
|
+
} catch {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function rawHostname(url: string): string {
|
|
98
|
+
const withoutProtocol = url.trim().replace(/^[a-z][a-z\d+.-]*:\/\//i, "")
|
|
99
|
+
const authority = withoutProtocol.split(/[/?#]/, 1)[0] || ""
|
|
100
|
+
const hostPort = authority.includes("@") ? authority.slice(authority.lastIndexOf("@") + 1) : authority
|
|
101
|
+
if (hostPort.startsWith("[")) {
|
|
102
|
+
return hostPort.slice(1, hostPort.indexOf("]")).toLowerCase()
|
|
103
|
+
}
|
|
104
|
+
return hostPort.split(":")[0].toLowerCase()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function openExternalUrl(url: string): void {
|
|
108
|
+
if (!isSafeUrl(url)) return
|
|
109
|
+
const platform = typeof process === "object" ? process.platform : ""
|
|
110
|
+
const command =
|
|
111
|
+
platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : ""
|
|
112
|
+
if (!command) return
|
|
113
|
+
try {
|
|
114
|
+
const child = execFile(command, [url], { timeout: 2000 }, () => {})
|
|
115
|
+
child.unref()
|
|
116
|
+
} catch {
|
|
117
|
+
// Opening destinations is best-effort; display must never fail.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function cleanLine(value: unknown): string {
|
|
122
|
+
return String(value || "")
|
|
123
|
+
.replace(
|
|
124
|
+
/(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
|
|
125
|
+
" ",
|
|
126
|
+
)
|
|
127
|
+
.replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
|
|
128
|
+
.replace(/\s+/g, " ")
|
|
129
|
+
.trim()
|
|
130
|
+
.slice(0, 120)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseServePayload(payload: unknown): ActiveServe | null {
|
|
134
|
+
if (!payload || typeof payload !== "object") return null
|
|
135
|
+
const r = payload as Record<string, unknown>
|
|
136
|
+
const serveId = typeof r.serve_id === "string" ? r.serve_id.trim() : ""
|
|
137
|
+
if (serveId.length < 8) return null
|
|
138
|
+
const serveReceipt = typeof r.serve_receipt === "string" ? r.serve_receipt.trim() : ""
|
|
139
|
+
if (serveReceipt.length < 32) return null
|
|
140
|
+
const creative = r.creative
|
|
141
|
+
if (!creative || typeof creative !== "object") return null
|
|
142
|
+
const c = creative as Record<string, unknown>
|
|
143
|
+
const line = cleanLine(c.line)
|
|
144
|
+
if (!line) return null
|
|
145
|
+
const destinationUrl = typeof c.destination_url === "string" ? c.destination_url.trim() : ""
|
|
146
|
+
if (!destinationUrl || !isSafeUrl(destinationUrl)) return null
|
|
147
|
+
const minVisibleMs = typeof r.min_visible_ms === "number" && r.min_visible_ms >= MIN_VISIBLE_MS
|
|
148
|
+
? r.min_visible_ms
|
|
149
|
+
: MIN_VISIBLE_MS
|
|
150
|
+
return { serveId, line, destinationUrl, serveReceipt, shownAt: Date.now(), minVisibleMs }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function fetchNextServe(): Promise<void> {
|
|
154
|
+
if (isPolling) return
|
|
155
|
+
const cfg = await resolveConfig()
|
|
156
|
+
if (!cfg) return
|
|
157
|
+
isPolling = true
|
|
158
|
+
const controller = new AbortController()
|
|
159
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(`${cfg.baseUrl}/v1/serve/next`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
|
|
164
|
+
body: JSON.stringify({ install_id: cfg.installId }),
|
|
165
|
+
signal: controller.signal,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
if (response.status === 204) { clearAd(); return }
|
|
169
|
+
if (!response.ok) return
|
|
170
|
+
|
|
171
|
+
const parsed = parseServePayload(await response.json())
|
|
172
|
+
if (!parsed) return
|
|
173
|
+
|
|
174
|
+
activeServe = parsed
|
|
175
|
+
setSponsorLine(parsed.line)
|
|
176
|
+
setDestinationUrl(parsed.destinationUrl)
|
|
177
|
+
|
|
178
|
+
if (impressionTimer) clearTimeout(impressionTimer)
|
|
179
|
+
impressionTimer = setTimeout(() => {
|
|
180
|
+
impressionTimer = undefined
|
|
181
|
+
if (!activeServe || activeServe.serveId !== parsed.serveId) return
|
|
182
|
+
recordImpression(parsed.serveId, parsed.serveReceipt, Math.max(Date.now() - parsed.shownAt, parsed.minVisibleMs))
|
|
183
|
+
}, parsed.minVisibleMs)
|
|
184
|
+
} catch {
|
|
185
|
+
// Network errors are non-fatal.
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timeout)
|
|
188
|
+
isPolling = false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function recordImpression(serveId: string, serveReceipt: string, visibleMs: number): Promise<void> {
|
|
193
|
+
const cfg = await resolveConfig()
|
|
194
|
+
if (!cfg) return
|
|
195
|
+
const controller = new AbortController()
|
|
196
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
197
|
+
try {
|
|
198
|
+
await fetch(`${cfg.baseUrl}/v1/events/impression`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
|
|
201
|
+
body: JSON.stringify({ serve_id: serveId, serve_receipt: serveReceipt, install_id: cfg.installId, visible_ms: visibleMs }),
|
|
202
|
+
signal: controller.signal,
|
|
203
|
+
})
|
|
204
|
+
} catch {
|
|
205
|
+
// Impression failures are non-fatal.
|
|
206
|
+
} finally {
|
|
207
|
+
clearTimeout(timeout)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function clearAd(): void {
|
|
212
|
+
if (impressionTimer) { clearTimeout(impressionTimer); impressionTimer = undefined }
|
|
213
|
+
activeServe = null
|
|
214
|
+
setSponsorLine("")
|
|
215
|
+
setDestinationUrl("")
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function startPolling(): void {
|
|
219
|
+
if (pollTimer) return
|
|
220
|
+
void fetchNextServe()
|
|
221
|
+
pollTimer = setInterval(() => { void fetchNextServe() }, POLL_INTERVAL_MS)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stopPolling(): void {
|
|
225
|
+
if (pollTimer) { clearInterval(pollTimer); pollTimer = undefined }
|
|
226
|
+
if (impressionTimer) { clearTimeout(impressionTimer); impressionTimer = undefined }
|
|
227
|
+
activeServe = null
|
|
228
|
+
setSponsorLine("")
|
|
229
|
+
setDestinationUrl("")
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
startPolling()
|
|
233
|
+
api.lifecycle.onDispose(stopPolling)
|
|
234
|
+
|
|
235
|
+
api.slots.register({
|
|
236
|
+
slots: {
|
|
237
|
+
app_bottom() {
|
|
238
|
+
const [hovered, setHovered] = createSignal(false)
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<Show when={sponsorLine()}>
|
|
242
|
+
<box
|
|
243
|
+
flexShrink={0}
|
|
244
|
+
paddingLeft={1}
|
|
245
|
+
paddingRight={1}
|
|
246
|
+
backgroundColor={hovered() ? api.theme.current.backgroundElement : api.theme.current.backgroundPanel}
|
|
247
|
+
onMouseOver={() => setHovered(true)}
|
|
248
|
+
onMouseOut={() => setHovered(false)}
|
|
249
|
+
onMouseUp={() => {
|
|
250
|
+
const url = destinationUrl()
|
|
251
|
+
if (url) {
|
|
252
|
+
openExternalUrl(url)
|
|
253
|
+
}
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
<text fg={hovered() ? api.theme.current.text : api.theme.current.textMuted}>
|
|
257
|
+
⧉ {sponsorLine()}
|
|
258
|
+
</text>
|
|
259
|
+
</box>
|
|
260
|
+
</Show>
|
|
261
|
+
)
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export default plugin
|