Brew.link to Aiden

Send Brew.link profiles directly to your Fellow Aiden

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Brew.link to Aiden
// @namespace    NewsGuyTor
// @version      1.2
// @description  Send Brew.link profiles directly to your Fellow Aiden
// @match        https://brew.link/p/*
// @license MIT
// @grant        GM.xmlHttpRequest
// @grant        GM_xmlHttpRequest
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    /****************************************************
     * 1) Detect Which “GM” HTTP Function is Available
     ****************************************************/

    let gmRequest = null;
    if (typeof GM_xmlHttpRequest !== 'undefined') {
      // Tampermonkey-style
      gmRequest = GM_xmlHttpRequest;
    } else if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function') {
      // Violentmonkey-style
      gmRequest = GM.xmlHttpRequest;
    }

    // Fallback: if no GM_ method is found, we’ll use fetch() (may fail with CORS).
    function doHttpRequest({method, url, headers, data, onload, onerror}) {
      if (gmRequest) {
        gmRequest({
          method,
          url,
          headers,
          data,
          onload,
          onerror
        });
      } else {
        fetch(url, {
          method,
          headers,
          body: data
        })
        .then(async (resp) => {
          const text = await resp.text();
          onload({ status: resp.status, responseText: text });
        })
        .catch((err) => {
          onerror(err);
        });
      }
    }

    /****************************************************
     * 2) Config / Constants
     ****************************************************/

    const BASE_URL = 'https://l8qtmnc692.execute-api.us-west-2.amazonaws.com/v1';
    const LOGIN_ENDPOINT = '/auth/login';
    const DEVICES_ENDPOINT = '/devices';
    const PROFILES_ENDPOINT = (id) => `/devices/${id}/profiles`;
    const SHARED_PROFILE_ENDPOINT = (brewId) => `/shared/${brewId}`;

    // Fields we strip out before creating a new profile
    const SERVER_SIDE_PROFILE_FIELDS = [
      'id', 'createdAt', 'deletedAt', 'lastUsedTime',
      'sharedFrom', 'isDefaultProfile', 'instantBrew',
      'folder', 'duration', 'lastGBQuantity'
    ];

    // Token validity duration in milliseconds (30 minutes)
    const TOKEN_VALIDITY_DURATION = 30 * 60 * 1000;

    /****************************************************
     * 3) CSS (gradient button, bigger, animations, etc.)
     ****************************************************/
    const STYLE = `
      /* Big gradient "Send to Aiden" button, pinned top-right */
      #sendToAidenBtn {
        position: fixed;
        top: 20px;
        right: 20px;
        padding: 16px 30px;
        font-size: 18px;
        font-family: sans-serif;
        font-weight: bold;
        color: #fff;
        background: linear-gradient(135deg, #111, #444);
        border: none;
        border-radius: 8px;
        cursor: pointer;
        z-index: 100000;
        transition: transform 0.3s ease, box-shadow 0.3s ease;
        box-shadow: 0 4px 10px rgba(0,0,0,0.4);
      }
      #sendToAidenBtn:hover {
        transform: scale(1.06);
        box-shadow: 0 6px 14px rgba(0,0,0,0.6);
      }
      /* Subtle 'pop in' animation on page load */
      #sendToAidenBtn {
        animation: aidenBtnPop 0.7s ease 0s 1 normal forwards;
      }
      @keyframes aidenBtnPop {
        0% {
          transform: scale(0.7);
          opacity: 0;
        }
        100% {
          transform: scale(1);
          opacity: 1;
        }
      }

      /* Backdrop for modals */
      #aidenBackdrop {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(0,0,0,0.5);
        z-index: 99998;
        display: none;
      }

      /********************************************
       * "Billion-dollar startup" style login form
       ********************************************/
      #aidenLoginForm {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        width: 340px;
        background: linear-gradient(135deg, #fff, #f4f4f4);
        border-radius: 10px;
        box-shadow: 0 6px 14px rgba(0,0,0,0.25);
        z-index: 99999;
        display: none;
        padding: 24px;
        font-family: "Helvetica Neue", Arial, sans-serif;
        animation: loginFormPop 0.3s ease forwards;
      }
      @keyframes loginFormPop {
        0% {
          transform: translate(-50%, -50%) scale(0.7);
          opacity: 0;
        }
        100% {
          transform: translate(-50%, -50%) scale(1);
          opacity: 1;
        }
      }
      #aidenLoginHeader {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 16px;
      }
      #aidenLoginHeader h3 {
        margin: 0;
        font-size: 20px;
      }
      #aidenLoginClose {
        cursor: pointer;
        color: #a33;
        font-weight: bold;
        font-size: 20px;
      }
      #aidenLoginForm input {
        display: block;
        width: 100%;
        margin: 10px 0;
        padding: 10px;
        font-size: 14px;
        border: 1px solid #ccc;
        border-radius: 4px;
      }
      #aidenLoginButton {
        width: 100%;
        padding: 12px;
        border: none;
        border-radius: 4px;
        background: linear-gradient(135deg, #111, #444);
        color: #fff;
        font-size: 15px;
        font-weight: bold;
        cursor: pointer;
        margin-top: 10px;
        transition: transform 0.2s ease;
      }
      #aidenLoginButton:hover {
        transform: scale(1.03);
      }
      #aidenMessage {
        color: #b00;
        margin-top: 6px;
        min-height: 1.2em;
      }

      /********************************************
       * Success modal
       ********************************************/
      #aidenSuccessModal {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        width: 380px;
        background: #fff;
        border-radius: 8px;
        box-shadow: 0 6px 14px rgba(0,0,0,0.25);
        z-index: 99999;
        display: none;
        padding: 20px;
        font-family: "Helvetica Neue", Arial, sans-serif;
      }
      #aidenSuccessModal h2 {
        margin-top: 0;
        font-size: 20px;
      }
      #successDetailsToggle {
        color: #1d72b8;
        text-decoration: underline;
        cursor: pointer;
        margin-top: 10px;
        display: inline-block;
      }
      #successDetails {
        margin-top: 10px;
        padding: 10px;
        background: #f9f9f9;
        border: 1px solid #ddd;
        border-radius: 4px;
        display: none; /* hidden by default */
        max-height: 300px;
        overflow-y: auto;
        white-space: pre-wrap;
      }
      #successCloseBtn {
        margin-top: 12px;
        padding: 8px 12px;
        border: none;
        border-radius: 4px;
        background: #888;
        color: #fff;
        cursor: pointer;
      }
      #successCloseBtn:hover {
        background: #666;
      }
    `;

    // Insert <style> into <head>
    const styleEl = document.createElement('style');
    styleEl.textContent = STYLE;
    document.head.appendChild(styleEl);

    /****************************************************
     * 4) DOM Setup
     ****************************************************/

    // Big “Send to Aiden” button
    const button = document.createElement('button');
    button.id = 'sendToAidenBtn';
    button.textContent = 'Send to Aiden';
    document.body.appendChild(button);

    // Backdrop (shared by login + success modal)
    const backdrop = document.createElement('div');
    backdrop.id = 'aidenBackdrop';
    document.body.appendChild(backdrop);

    // Login form
    const loginForm = document.createElement('div');
    loginForm.id = 'aidenLoginForm';
    loginForm.innerHTML = `
      <div id="aidenLoginHeader">
        <h3>Fellow Aiden Login</h3>
        <div id="aidenLoginClose">&#x2716;</div>
      </div>
      <input type="text" id="aidenEmail" placeholder="Email" />
      <input type="password" id="aidenPassword" placeholder="Password" />
      <button id="aidenLoginButton">Login</button>
      <div id="aidenMessage"></div>
    `;
    document.body.appendChild(loginForm);

    // Success modal
    const successModal = document.createElement('div');
    successModal.id = 'aidenSuccessModal';
    successModal.innerHTML = `
      <h2>Profile Created!</h2>
      <p>Your brew profile has been sent to Aiden.</p>
      <span id="successDetailsToggle">Show details</span>
      <div id="successDetails"></div>
      <button id="successCloseBtn">Close</button>
    `;
    document.body.appendChild(successModal);

    // Refs
    const loginClose       = document.getElementById('aidenLoginClose');
    const loginButton      = document.getElementById('aidenLoginButton');
    const emailField       = document.getElementById('aidenEmail');
    const passwordField    = document.getElementById('aidenPassword');
    const messageField     = document.getElementById('aidenMessage');
    const successDetails   = document.getElementById('successDetails');
    const successModalEl   = document.getElementById('aidenSuccessModal');
    const successToggle    = document.getElementById('successDetailsToggle');
    const successCloseBtn  = document.getElementById('successCloseBtn');

    /****************************************************
     * 5) Utilities
     ****************************************************/

    // Show/hide the backdrop + login form
    function showLoginForm() {
      backdrop.style.display = 'block';
      loginForm.style.display = 'block';
      messageField.textContent = '';
    }
    function hideLoginForm() {
      backdrop.style.display = 'none';
      loginForm.style.display = 'none';
      messageField.textContent = '';
    }
    // Show/hide success modal
    function showSuccessModal() {
      backdrop.style.display = 'block';
      successModalEl.style.display = 'block';
    }
    function hideSuccessModal() {
      backdrop.style.display = 'none';
      successModalEl.style.display = 'none';
      successDetails.style.display = 'none'; // ensure collapsed
    }

    function getToken() {
      const token = localStorage.getItem('aiden_access_token');
      const timestamp = localStorage.getItem('aiden_token_timestamp');
      if (!token || !timestamp) return null;
      const now = Date.now();
      if (now - parseInt(timestamp, 10) > TOKEN_VALIDITY_DURATION) {
        // Token has expired
        clearTokens();
        return null;
      }
      return token;
    }

    function storeTokens(access, refresh) {
      localStorage.setItem('aiden_access_token', access);
      if (refresh) {
        localStorage.setItem('aiden_refresh_token', refresh);
      }
      localStorage.setItem('aiden_token_timestamp', Date.now().toString());
    }

    function clearTokens() {
      localStorage.removeItem('aiden_access_token');
      localStorage.removeItem('aiden_refresh_token');
      localStorage.removeItem('aiden_token_timestamp');
      localStorage.removeItem('aiden_brewer_id');
    }

    // Core fetch using doHttpRequest with enhanced error handling
    function gmFetch(endpoint, { method='GET', body=null, requireAuth=true } = {}) {
      return new Promise((resolve, reject) => {
        const url = `${BASE_URL}${endpoint}`;
        const headers = {
          'User-Agent': 'Fellow/5 CFNetwork/1568.300.101 Darwin/24.2.0',
          'Content-Type': 'application/json'
        };
        // Only add Authorization if needed
        if (requireAuth && getToken()) {
          headers['Authorization'] = `Bearer ${getToken()}`;
        }

        doHttpRequest({
          method,
          url,
          headers,
          data: body,
          onload: (resp) => {
            if (resp.status >= 200 && resp.status < 300) {
              try {
                resolve(JSON.parse(resp.responseText));
              } catch {
                resolve(resp.responseText);
              }
            } else if (resp.status === 401) {
              // Unauthorized - Token might be invalid or expired
              clearTokens();
              promptReLogin(`Unauthorized access. Please log in again.`);
              reject(new Error(`HTTP ${resp.status}: ${resp.responseText}`));
            } else {
              reject(new Error(`HTTP ${resp.status}: ${resp.responseText}`));
            }
          },
          onerror: (err) => {
            reject(new Error(`NetworkError: ${JSON.stringify(err)}`));
          }
        });
      });
    }

    // Parse the brew link ID from the current URL
    function parseBrewLinkID(url) {
      // pattern: (?:.*?/p/)?([a-zA-Z0-9]+)/?$
      const pattern = /(?:.*\/p\/)?([a-zA-Z0-9]+)\/?$/;
      const match = url.match(pattern);
      if (!match) {
        return null;
      }
      return match[1];
    }

    // Remove server-only fields
    function stripServerFields(profile) {
      SERVER_SIDE_PROFILE_FIELDS.forEach(f => delete profile[f]);
      return profile;
    }

    // Prompt user to re-login with an optional message
    function promptReLogin(message) {
      if (message) {
        alert(message);
      }
      showLoginForm();
    }

    /****************************************************
     * 6) Core Logic
     ****************************************************/

    // Get (or fetch) the device ID
    async function getDeviceId() {
      const existingID = localStorage.getItem('aiden_brewer_id');
      if (existingID) return existingID;

      const devices = await gmFetch(DEVICES_ENDPOINT, { method: 'GET' });
      if (!Array.isArray(devices) || devices.length === 0) {
        throw new Error('No devices found for this account.');
      }
      const device = devices[0]; // assume single brewer
      localStorage.setItem('aiden_brewer_id', device.id);
      return device.id;
    }

    // Login to Aiden
    async function loginToAiden(email, password) {
      const payload = JSON.stringify({ email, password });
      const data = await gmFetch(LOGIN_ENDPOINT, { method: 'POST', body: payload, requireAuth: false });
      if (!data.accessToken) {
        throw new Error('No accessToken returned from server.');
      }
      storeTokens(data.accessToken, data.refreshToken || '');
      localStorage.removeItem('aiden_brewer_id'); // re-fetch in case changed
      return true;
    }

    // Send the brew profile to Aiden
    async function sendProfileToAiden() {
      // 1) Parse brew link from URL
      const brewId = parseBrewLinkID(window.location.href);
      if (!brewId) {
        throw new Error('Invalid brew.link URL or ID format');
      }
      // 2) Fetch shared profile (assume public; set requireAuth: false)
      const sharedProfile = await gmFetch(SHARED_PROFILE_ENDPOINT(brewId), {
        method: 'GET',
        requireAuth: false
      });
      // 3) Strip server-only fields
      stripServerFields(sharedProfile);
      // 4) Create profile on user’s device
      const brewerId = await getDeviceId();
      const createdProfile = await gmFetch(PROFILES_ENDPOINT(brewerId), {
        method: 'POST',
        body: JSON.stringify(sharedProfile)
      });
      // 5) Show success modal with details
      showProfileSuccess(createdProfile);
    }

    // Show success modal with hidden JSON details
    function showProfileSuccess(profileData) {
      successDetails.textContent = JSON.stringify(profileData, null, 2);
      showSuccessModal();
    }

    /****************************************************
     * 7) Automatic Title Replacement
     ****************************************************/
    (async function setDocumentTitle() {
      try {
        const brewId = parseBrewLinkID(window.location.href);
        if (!brewId) return; // Not a valid brew link => do nothing
        // Fetch shared profile without requiring auth
        const sharedProfile = await gmFetch(SHARED_PROFILE_ENDPOINT(brewId), {
          method: 'GET',
          requireAuth: false
        });
        if (sharedProfile && typeof sharedProfile.title === 'string') {
          document.title = `${sharedProfile.title} - brew.link`;
        }
      } catch (e) {
        // If any error occurs, silently ignore and keep default title
      }
    })();

    /****************************************************
     * 8) Event Handlers
     ****************************************************/

    // “Send to Aiden” button
    button.addEventListener('click', async () => {
      if (!getToken()) {
        // No valid token => show login
        showLoginForm();
        return;
      }
      // If we do have a valid token, attempt the send
      try {
        await sendProfileToAiden();
      } catch (err) {
        // Handle specific cases
        if (err.message.includes('HTTP 401')) {
          // Already handled in gmFetch, but you can add additional actions here if needed
          console.error('Unauthorized. Prompting re-login.');
        } else {
          alert(`Failed sending profile: ${err.message}`);
        }
      }
    });

    // Close login form
    loginClose.addEventListener('click', () => {
      hideLoginForm();
    });

    // Login button
    loginButton.addEventListener('click', async () => {
      const email = emailField.value.trim();
      const password = passwordField.value.trim();
      if (!email || !password) {
        messageField.textContent = 'Email and password cannot be empty!';
        return;
      }
      messageField.textContent = 'Logging in...';
      try {
        await loginToAiden(email, password);
        messageField.textContent = 'Login successful!';
        hideLoginForm();
        // Immediately send after successful login
        await sendProfileToAiden();
      } catch (err) {
        messageField.textContent = `Login failed: ${err.message}`;
      }
    });

    // Success modal: show/hide details
    successToggle.addEventListener('click', () => {
      if (successDetails.style.display === 'none') {
        successDetails.style.display = 'block';
        successToggle.textContent = 'Hide details';
      } else {
        successDetails.style.display = 'none';
        successToggle.textContent = 'Show details';
      }
    });

    // Success modal: close button
    successCloseBtn.addEventListener('click', () => {
      hideSuccessModal();
    });

})();