/*! * plicflix.js 2.4 — o SDK do jogo parceiro. Sem dependências. * Guia completo: https://developers.plicflix.com/docs/SDK * Para IAs: https://developers.plicflix.com/llms.txt * * * * * FORA do app (o seu site, o seu app): "Entrar com Plicflix" — OAuth 2.0 + PKCE (guia: /docs/OAUTH) * Plicflix.botaoEntrar("#entrar", { cliente: "seu-jogo", volta: "https://seu-jogo.com/volta" }); * // na página de volta: * const r = await Plicflix.voltouDoPlicflix({ servidor: "/api/plicflix/oauth" }); // ou sem servidor * // r.jogador = { id: "pf_…", nome } — o MESMO id da entrada pelo app * * Regras (o guia explica cada uma): * - o plicflix nunca hospeda o seu jogo: abre o SEU servidor por cima do app; * - o jogo recebe APENAS UM TOKEN (5 minutos ou uma troca); quem descobre o * jogador é o seu servidor, trocando o token com a sua chave pk_…; * - do jogador chegam só o código ("pf_…", fixo no seu jogo) e o apelido; * - a bolha do plicflix fica por cima do jogo (avisos, amigos, sair): o jogo * conversa com ela por este SDK. */ (function (global) { "use strict"; var VERSAO = "2.4.0"; var CHAVE_OAUTH = "plicflix:oauth"; var APP = "https://plicflix.com"; var CHAVE = "plicflix:ingresso"; var CHAVE_JOGADOR = "plicflix:jogador"; var token = null; var api = "https://api.plicflix.com"; var ctx = { ref: "plicflix", idioma: "pt-BR", plataforma: "web" }; // ---------------------------------------------------------------- o endereço // O app abre: https://seu-jogo/?ref=plicflix#plicflix=&pf_idioma=pt-BR&pf_plataforma=web try { var resto = []; global.location.hash.replace(/^#/, "").split("&").forEach(function (par) { var i = par.indexOf("="), k = i < 0 ? par : par.slice(0, i), v = i < 0 ? "" : decodeURIComponent(par.slice(i + 1)); if (k === "plicflix") token = v; else if (k === "pf_idioma") ctx.idioma = v; else if (k === "pf_plataforma") ctx.plataforma = v; else if (k === "pf_api") api = v; else if (par) resto.push(par); }); if (token) { // tira o token da barra de endereço (não vaza se alguém copiar o link) global.history.replaceState(global.history.state, "", global.location.pathname + global.location.search + (resto.length ? "#" + resto.join("&") : "")); global.sessionStorage.setItem(CHAVE, JSON.stringify({ token: token, ctx: ctx, api: api })); } else { // recarregou antes de trocar o token: ele continua nesta aba var guardado = JSON.parse(global.sessionStorage.getItem(CHAVE) || "null"); if (guardado) { token = guardado.token; ctx = guardado.ctx; if (guardado.api) api = guardado.api; } } } catch (e) { /* sem sessionStorage: segue só com o fragmento */ } var dentro = !!token || /[?&]ref=plicflix(&|$)/.test(global.location.search); // ---------------------------------------------------------------- jogo → app function avisarApp(msg) { msg.v = 2; // celular (WebView do app) if (global.PlicflixApp && global.PlicflixApp.postMessage) global.PlicflixApp.postMessage(JSON.stringify(msg)); // web (iframe dentro do app) if (global.parent && global.parent !== global) global.parent.postMessage({ plicflix: msg }, "*"); } // ---------------------------------------------------------------- app → jogo var aoSairFn = null, aoIrParaFn = null, saindo = false; function comoResultado(r) { if (r === undefined || r === null || r === true) return { ok: true }; if (r === false) return { ok: false }; return { ok: r.ok !== false, motivo: r.motivo ? String(r.motivo).slice(0, 160) : undefined }; } function receber(msg) { if (!msg) return; if (msg.tipo === "saindo") { if (saindo) return; saindo = true; var fim = function () { avisarApp({ tipo: "saiu" }); }; try { Promise.resolve(aoSairFn ? aoSairFn() : null).then(fim, fim); } catch (e) { fim(); } } else if (msg.tipo === "irPara") { var responder = function (r) { var res = comoResultado(r); avisarApp({ tipo: "foiPara", ok: res.ok, motivo: res.motivo, pedido: msg.pedido }); }; if (!aoIrParaFn) return responder({ ok: false, motivo: "Este jogo ainda não sabe levar você até outra pessoa." }); try { Promise.resolve(aoIrParaFn(msg.destino || {})).then(responder, function (e) { responder({ ok: false, motivo: (e && e.message) || "Não foi possível ir até lá." }); }); } catch (e) { responder({ ok: false, motivo: e.message }); } } } // web: o app (a página que contém o iframe) manda por postMessage global.addEventListener("message", function (e) { if (e.source === global.parent && e.data && e.data.plicflixApp) receber(e.data.plicflixApp); }); // celular: o app chama esta função pela WebView global.__plicflixApp = function (msg) { receber(typeof msg === "string" ? JSON.parse(msg) : msg); }; var ultimoOnde = ""; var Plicflix = { versao: VERSAO, // ---- entrada ---- /** true quando o jogo foi aberto pelo plicflix. */ dentro: function () { return dentro; }, /** O token de uso único (ou null). O SEU servidor troca na API do plicflix. */ token: function () { return token; }, /** { ref: "plicflix", idioma: "pt-BR", plataforma: "web" | "android" | "ios" } */ contexto: function () { return { ref: ctx.ref, idioma: ctx.idioma, plataforma: ctx.plataforma }; }, /** * Manda o token para o SEU servidor (POST JSON { token, contexto }) e * devolve a resposta dele. Depois o token é esquecido (vale uma vez só). */ entrar: function (url, opcoes) { if (!token) return Promise.reject(new Error("plicflix: sem token (o jogo não foi aberto pelo plicflix)")); opcoes = opcoes || {}; return global .fetch(url, { method: "POST", credentials: opcoes.credentials || "include", headers: Object.assign({ "Content-Type": "application/json" }, opcoes.headers || {}), body: JSON.stringify({ token: token, contexto: Plicflix.contexto() }), }) .then(function (r) { try { global.sessionStorage.removeItem(CHAVE); } catch (e) {} token = null; return r.json().then(function (j) { if (!r.ok) throw Object.assign(new Error((j && (j.erro || j.error)) || "plicflix: o servidor do jogo recusou o login"), { status: r.status, resposta: j }); return j; }); }); }, /** * Jogo SEM servidor: troca o token direto da página e devolve * { jogador: { id, nome }, jogo, contexto, pelo_navegador: true }. * Só funciona na origem cadastrada do jogo. Como tudo que roda no navegador, * o resultado pode ser forjado por quem quiser: serve para mostrar o nome e * guardar progresso local, nunca para proteger moedas, ranking ou compras * (para isso, troque o token no SEU servidor com Plicflix.entrar). * Recarregou a página? Devolve o mesmo jogador (fica guardado nesta aba). */ jogador: function () { try { var ja = JSON.parse(global.sessionStorage.getItem(CHAVE_JOGADOR) || "null"); if (ja) return Promise.resolve(ja); } catch (e) {} if (!token) return Promise.reject(new Error("plicflix: sem token (o jogo não foi aberto pelo plicflix)")); return global .fetch(api.replace(/\/$/, "") + "/v1/ingresso/navegador", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: token }), }) .then(function (r) { return r.json().then(function (j) { if (!r.ok) throw Object.assign(new Error((j && j.erro) || "plicflix: troca recusada"), { status: r.status, resposta: j }); try { global.sessionStorage.removeItem(CHAVE); global.sessionStorage.setItem(CHAVE_JOGADOR, JSON.stringify(j)); } catch (e) {} token = null; return j; }); }); }, // ---- carregamento ---- /** Andamento do carregamento (0–100) e um texto curto: a tela do plicflix mostra. */ progresso: function (pct, texto) { avisarApp({ tipo: "progresso", pct: Math.max(0, Math.min(100, +pct || 0)), texto: texto ? String(texto).slice(0, 80) : "" }); }, /** O jogador já pode jogar: o plicflix tira a tela de carregamento. */ pronto: function () { avisarApp({ tipo: "pronto" }); }, /** Deu errado (servidor fora, manutenção…): o plicflix mostra a mensagem e oferece tentar de novo. */ erro: function (texto) { avisarApp({ tipo: "erro", texto: String(texto || "").slice(0, 160) }); }, // ---- sair ---- /** * O jogador vai sair (a bolha, o voltar do celular…): fn roda antes de o * app fechar o jogo — salve o progresso ali. Pode devolver uma Promise; o * app espera no máximo 5 s. */ aoSair: function (fn) { aoSairFn = typeof fn === "function" ? fn : null; }, /** O próprio jogo fecha e volta ao catálogo (depois de já ter salvo). */ sair: function () { avisarApp({ tipo: "sair" }); }, // ---- onde o jogador está, amigos ---- /** * Onde o jogador está. Chame sempre que ele mudar de lugar. * Plicflix.onde("Taverna do Porto") * Plicflix.onde({ texto: "Taverna do Porto", // o que as pessoas leem (até 60 letras) * area: "cidade/taverna", // para agrupar (minúsculas, números, / _ . -) * ponto: { cena: "taverna:1", x: 12, y: 8 } }) // só o SEU jogo entende (até 1 KB) * O plicflix mostra o texto na bolha e aos amigos (se a privacidade deixar), * guarda o "continuar de onde parou" e devolve o ponto ao seu jogo no "ir até". */ onde: function (o) { if (typeof o === "string") o = { texto: o }; if (!o || !o.texto) return; var msg = { tipo: "onde", texto: String(o.texto).slice(0, 60) }; if (o.area) msg.area = String(o.area).toLowerCase().slice(0, 60); if (o.ponto !== undefined && o.ponto !== null) { var json = JSON.stringify(o.ponto); if (typeof o.ponto !== "object" || json.length > 1024) { if (global.console) console.warn("plicflix: onde.ponto precisa ser um objeto de até 1 KB — ignorado"); } else msg.ponto = o.ponto; } var chave = JSON.stringify(msg); if (chave === ultimoOnde) return; // o mesmo lugar: não repete ultimoOnde = chave; avisarApp(msg); }, /** * "Ir até o amigo": o jogador escolheu ir até alguém que está no SEU jogo. * fn recebe o destino e deve levar o jogador até lá: * { jogador_id: "pf_…", // o código de quem está lá, no SEU jogo — use no SEU servidor * jogador: "Ana", // o apelido * texto: "Taverna do Porto", area: "cidade/taverna", * ponto: { … } } // o que você mesmo mandou em Plicflix.onde (pode faltar) * Devolva true / false, ou { ok, motivo } (pode ser Promise). O motivo * aparece para o jogador ("Ela está numa terra particular"). * IMPORTANTE: não teletransporte pelo ponto vindo do navegador — mande o * jogador_id para o seu servidor e ache lá a posição real. */ aoIrPara: function (fn) { aoIrParaFn = typeof fn === "function" ? fn : null; }, /** Abre a lista de amigos da bolha (ex.: um botão "Chamar amigos" no seu jogo). */ abrirAmigos: function () { avisarApp({ tipo: "abrirAmigos" }); }, // ---- "Entrar com Plicflix" (fora do app: OAuth 2.0 + PKCE — /docs/OAUTH) ---- /** * Manda o navegador para o plicflix (o jogador entra e autoriza) e volta para * `volta` com ?code=…&state=…. O PKCE e o state ficam nesta aba. * opcoes: { cliente: "seu-slug", volta: "https://…" (cadastrado no developers), * prompt?: "consent" (pergunta mesmo se já autorizou), plicflix?: "https://plicflix.com" } */ entrarComPlicflix: function (opcoes) { opcoes = opcoes || {}; if (!opcoes.cliente || !opcoes.volta) return Promise.reject(new Error("plicflix: informe { cliente, volta }")); var verificador = aleatorioUrl(48), estado = aleatorioUrl(16); return desafioS256(verificador).then(function (desafio) { try { global.sessionStorage.setItem(CHAVE_OAUTH, JSON.stringify({ verificador: verificador, estado: estado, cliente: opcoes.cliente, volta: opcoes.volta, api: opcoes.api || api })); } catch (e) {} var q = "response_type=code&client_id=" + encodeURIComponent(opcoes.cliente) + "&redirect_uri=" + encodeURIComponent(opcoes.volta) + "&state=" + estado + "&code_challenge=" + desafio + "&code_challenge_method=S256&scope=perfil" + (opcoes.prompt ? "&prompt=" + encodeURIComponent(opcoes.prompt) : ""); global.location.assign((opcoes.plicflix || APP).replace(/\/$/, "") + "/oauth/authorize?" + q); }); }, /** * Na página de volta: lê ?code / ?error, confere o state e troca o código. * - opcoes.servidor: a rota do SEU servidor — recebe POST JSON { code, code_verifier, redirect_uri }, * troca com a chave pk_ (sdk/servidor) e já faz o login; a resposta dele volta aqui. (Recomendado.) * - sem servidor: troca direto na API (cliente público) e devolve { jogador: { id, nome }, jogo, * assinante, access_token }. Como tudo no navegador: não protege nada de valor. * Sem ?code na URL: null. O jogador recusou: erro com .codigo = "access_denied". */ voltouDoPlicflix: function (opcoes) { opcoes = opcoes || {}; var q = new URLSearchParams(global.location.search), codigo = q.get("code"), estado = q.get("state"), falha = q.get("error"); if (!codigo && !falha) return Promise.resolve(null); var guardado = null; try { guardado = JSON.parse(global.sessionStorage.getItem(CHAVE_OAUTH) || "null"); global.sessionStorage.removeItem(CHAVE_OAUTH); } catch (e) {} ["code", "state", "error", "error_description"].forEach(function (k) { q.delete(k); }); var resto = q.toString(); global.history.replaceState(global.history.state, "", global.location.pathname + (resto ? "?" + resto : "") + global.location.hash); if (falha) return Promise.reject(Object.assign(new Error("plicflix: " + falha), { codigo: falha })); if (!guardado || guardado.estado !== estado) return Promise.reject(Object.assign(new Error("plicflix: state não confere (abra o login de novo)"), { codigo: "state" })); var corpo = { code: codigo, code_verifier: guardado.verificador, redirect_uri: guardado.volta }; if (opcoes.servidor) { return global .fetch(opcoes.servidor, { method: "POST", credentials: opcoes.credentials || "include", headers: Object.assign({ "Content-Type": "application/json" }, opcoes.headers || {}), body: JSON.stringify(corpo) }) .then(lerResposta); } corpo.grant_type = "authorization_code"; corpo.client_id = guardado.cliente; return global .fetch((opcoes.api || guardado.api || api).replace(/\/$/, "") + "/v1/oauth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(corpo) }) .then(lerResposta); }, /** * O botão oficial "Entrar com Plicflix" (ou "Sign in with Plicflix"…, pela língua da página). * Plicflix.botaoEntrar("#onde", { cliente, volta, texto?, largura?: "100%", tema?: "escuro" | "claro" }) * Ou um link para a SUA rota que começa o login no servidor: { href: "/api/plicflix/oauth" }. * Devolve o elemento. */ botaoEntrar: function (onde, opcoes) { opcoes = opcoes || {}; var el = typeof onde === "string" ? global.document.querySelector(onde) : onde; if (!el) throw new Error("plicflix: botaoEntrar — elemento não encontrado"); var b = global.document.createElement(opcoes.href ? "a" : "button"); if (opcoes.href) b.href = opcoes.href; else b.type = "button"; b.className = "plicflix-entrar"; var claro = opcoes.tema === "claro"; b.setAttribute("style", "display:inline-flex;align-items:center;justify-content:center;gap:10px;box-sizing:border-box;min-height:44px;padding:10px 18px;border-radius:12px;" + "font:700 15px/1.2 system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;text-decoration:none;cursor:pointer;border:" + (claro ? "1px solid #d9d2ef" : "0") + ";" + "color:" + (claro ? "#1a1033" : "#fff") + ";background:" + (claro ? "#fff" : "linear-gradient(90deg,#DC0DF7,#A104F8)") + ";width:" + (opcoes.largura || "auto") + ";"); b.innerHTML = ''; var t = global.document.createElement("span"); t.textContent = opcoes.texto || textoDoBotao(); b.appendChild(t); if (!opcoes.href) b.addEventListener("click", function () { b.disabled = true; Plicflix.entrarComPlicflix(opcoes).catch(function (e) { b.disabled = false; if (global.console) console.error(e); }); }); el.appendChild(b); return b; }, }; // ---------------------------------------------------------------- ajudantes do OAuth function aleatorioUrl(n) { var b = new Uint8Array(n); global.crypto.getRandomValues(b); return base64url(b); } function base64url(bytes) { var s = ""; for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return global.btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function desafioS256(v) { return global.crypto.subtle.digest("SHA-256", new TextEncoder().encode(v)).then(function (h) { return base64url(new Uint8Array(h)); }); } function lerResposta(r) { return r.json().catch(function () { return {}; }).then(function (j) { if (!r.ok) throw Object.assign(new Error("plicflix: " + ((j && (j.error_description || j.erro || j.error)) || r.status)), { status: r.status, codigo: j && j.error, resposta: j }); return j; }); } function textoDoBotao() { var l = ((global.document && global.document.documentElement.lang) || (global.navigator && global.navigator.language) || "en").toLowerCase(); if (l.indexOf("pt") === 0) return "Entrar com Plicflix"; if (l.indexOf("es") === 0) return "Entrar con Plicflix"; if (l.indexOf("fr") === 0) return "Se connecter avec Plicflix"; if (l.indexOf("de") === 0) return "Mit Plicflix anmelden"; if (l.indexOf("it") === 0) return "Accedi con Plicflix"; return "Sign in with Plicflix"; } global.Plicflix = Plicflix; if (typeof module === "object" && module.exports) module.exports = Plicflix; if (dentro) avisarApp({ tipo: "ola", versao: VERSAO, recursos: ["entrar", "jogador", "carregamento", "sair", "onde", "irPara", "amigos"] }); })(typeof window !== "undefined" ? window : this);