waitspin 0.1.2 → 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 +84 -18
- 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/assets/waitspin-vscode/out/extension.js +31 -9
- package/assets/waitspin-vscode/out/extension.js.map +1 -1
- package/assets/waitspin-vscode/package.json +1 -1
- package/dist/cli.js +2402 -78
- package/dist/cli.js.map +1 -1
- package/package.json +8 -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
|
|
@@ -71,7 +71,7 @@ function resolveApiKey() {
|
|
|
71
71
|
if (secretApiKey) {
|
|
72
72
|
return secretApiKey;
|
|
73
73
|
}
|
|
74
|
-
return
|
|
74
|
+
return undefined;
|
|
75
75
|
}
|
|
76
76
|
async function migratePublisherApiKeyToSecretStorage(context) {
|
|
77
77
|
let stored;
|
|
@@ -127,20 +127,33 @@ function readGlobalWaitSpinSetting(name) {
|
|
|
127
127
|
function allowDeveloperApiBase() {
|
|
128
128
|
return process.env.WAITSPIN_ALLOW_DEV_API_BASE === "1";
|
|
129
129
|
}
|
|
130
|
+
function isLoopbackApiHostname(hostname) {
|
|
131
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
|
132
|
+
return (normalized === "localhost" ||
|
|
133
|
+
normalized === "::1" ||
|
|
134
|
+
normalized === "[::1]" ||
|
|
135
|
+
/^127(?:\.\d{1,3}){3}$/.test(normalized));
|
|
136
|
+
}
|
|
130
137
|
function normalizeTrustedApiBase(value) {
|
|
131
138
|
try {
|
|
132
139
|
const parsed = new URL(value);
|
|
133
140
|
const hostname = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
|
134
|
-
const
|
|
135
|
-
hostname === "api.waitspin.com" &&
|
|
136
|
-
!parsed.username &&
|
|
141
|
+
const isCleanOrigin = !parsed.username &&
|
|
137
142
|
!parsed.password &&
|
|
143
|
+
!parsed.search &&
|
|
144
|
+
!parsed.hash &&
|
|
138
145
|
(parsed.pathname === "/" || parsed.pathname === "");
|
|
146
|
+
const isProductionApi = parsed.protocol === "https:" &&
|
|
147
|
+
hostname === "api.waitspin.com" &&
|
|
148
|
+
isCleanOrigin;
|
|
139
149
|
if (isProductionApi) {
|
|
140
150
|
return parsed.origin;
|
|
141
151
|
}
|
|
142
|
-
if (allowDeveloperApiBase() &&
|
|
143
|
-
|
|
152
|
+
if (allowDeveloperApiBase() &&
|
|
153
|
+
isCleanOrigin &&
|
|
154
|
+
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
155
|
+
isLoopbackApiHostname(parsed.hostname)) {
|
|
156
|
+
return parsed.origin;
|
|
144
157
|
}
|
|
145
158
|
}
|
|
146
159
|
catch {
|
|
@@ -196,6 +209,12 @@ function parseServePayload(payload) {
|
|
|
196
209
|
if (typeof serveId !== "string" || serveId.trim().length < 8) {
|
|
197
210
|
return undefined;
|
|
198
211
|
}
|
|
212
|
+
const serveReceipt = record.serve_receipt;
|
|
213
|
+
if (typeof serveReceipt !== "string" ||
|
|
214
|
+
serveReceipt.trim().length < 32 ||
|
|
215
|
+
serveReceipt.length > 2048) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
199
218
|
const creative = record.creative;
|
|
200
219
|
if (!creative || typeof creative !== "object") {
|
|
201
220
|
return undefined;
|
|
@@ -217,6 +236,7 @@ function parseServePayload(payload) {
|
|
|
217
236
|
serveId: serveId.trim(),
|
|
218
237
|
line: line.trim(),
|
|
219
238
|
destinationUrl: destinationUrl.trim(),
|
|
239
|
+
serveReceipt: serveReceipt.trim(),
|
|
220
240
|
minVisibleMs,
|
|
221
241
|
};
|
|
222
242
|
}
|
|
@@ -304,6 +324,7 @@ async function fetchNextCreative() {
|
|
|
304
324
|
serveId: parsed.serveId,
|
|
305
325
|
line: parsed.line,
|
|
306
326
|
destinationUrl: parsed.destinationUrl,
|
|
327
|
+
serveReceipt: parsed.serveReceipt,
|
|
307
328
|
shownAt: Date.now(),
|
|
308
329
|
minVisibleMs: parsed.minVisibleMs,
|
|
309
330
|
};
|
|
@@ -331,7 +352,7 @@ function showStatusBarAd(serve, minVisibleMs) {
|
|
|
331
352
|
return;
|
|
332
353
|
}
|
|
333
354
|
const visibleMs = Math.max(Date.now() - serve.shownAt, minVisibleMs);
|
|
334
|
-
void recordImpression(serve.serveId, visibleMs, resolveInstallId());
|
|
355
|
+
void recordImpression(serve.serveId, serve.serveReceipt, visibleMs, resolveInstallId());
|
|
335
356
|
}, minVisibleMs);
|
|
336
357
|
}
|
|
337
358
|
function flushPendingImpressionIfEligible() {
|
|
@@ -341,7 +362,7 @@ function flushPendingImpressionIfEligible() {
|
|
|
341
362
|
}
|
|
342
363
|
const visibleMs = Date.now() - serve.shownAt;
|
|
343
364
|
if (visibleMs >= serve.minVisibleMs) {
|
|
344
|
-
void recordImpression(serve.serveId, visibleMs, resolveInstallId());
|
|
365
|
+
void recordImpression(serve.serveId, serve.serveReceipt, visibleMs, resolveInstallId());
|
|
345
366
|
}
|
|
346
367
|
}
|
|
347
368
|
function clearImpressionSchedulingAndFlush() {
|
|
@@ -380,7 +401,7 @@ function startPollingIfConfigured() {
|
|
|
380
401
|
void fetchNextCreative();
|
|
381
402
|
}, POLL_INTERVAL_MS);
|
|
382
403
|
}
|
|
383
|
-
async function recordImpression(serveId, visibleMs, installId) {
|
|
404
|
+
async function recordImpression(serveId, serveReceipt, visibleMs, installId) {
|
|
384
405
|
const apiKey = resolveApiKey();
|
|
385
406
|
const apiBase = resolveApiBase();
|
|
386
407
|
if (!apiKey || !installId || !apiBase) {
|
|
@@ -395,6 +416,7 @@ async function recordImpression(serveId, visibleMs, installId) {
|
|
|
395
416
|
},
|
|
396
417
|
body: JSON.stringify({
|
|
397
418
|
serve_id: serveId,
|
|
419
|
+
serve_receipt: serveReceipt,
|
|
398
420
|
install_id: installId,
|
|
399
421
|
visible_ms: visibleMs,
|
|
400
422
|
}),
|