mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-31 21:21:41 +00:00
chore(extension): bump version, fix targeted injection and race conditions
This commit is contained in:
+54
-35
@@ -30,8 +30,10 @@ function ensureState() {
|
|||||||
chrome.storage.session.get([
|
chrome.storage.session.get([
|
||||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime'
|
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle'
|
||||||
], (data) => {
|
], (data) => {
|
||||||
|
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
|
||||||
|
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
|
||||||
// Merge data from storage with any early-arriving state
|
// Merge data from storage with any early-arriving state
|
||||||
// New entries (added during boot) must stay at the top (index 0)
|
// New entries (added during boot) must stay at the top (index 0)
|
||||||
if (data.logs) logs = [...logs, ...data.logs].slice(0, 50);
|
if (data.logs) logs = [...logs, ...data.logs].slice(0, 50);
|
||||||
@@ -121,13 +123,12 @@ async function getPeerId() {
|
|||||||
|
|
||||||
async function getSettings() {
|
async function getSettings() {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId', 'username'], (data) => {
|
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
|
||||||
resolve({
|
resolve({
|
||||||
serverUrl: data.serverUrl || '',
|
serverUrl: data.serverUrl || '',
|
||||||
useCustomServer: data.useCustomServer || false,
|
useCustomServer: data.useCustomServer || false,
|
||||||
roomId: data.roomId || '',
|
roomId: data.roomId || '',
|
||||||
password: data.password || '',
|
password: data.password || '',
|
||||||
targetTabId: data.targetTabId || null,
|
|
||||||
username: data.username || ''
|
username: data.username || ''
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -587,10 +588,6 @@ function updateLastAction(action, senderId, timestamp = Date.now()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function routeToContent(action, payload) {
|
async function routeToContent(action, payload) {
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.targetTabId) {
|
|
||||||
currentTabId = settings.targetTabId;
|
|
||||||
}
|
|
||||||
if (!currentTabId) return;
|
if (!currentTabId) return;
|
||||||
|
|
||||||
const tabId = parseInt(currentTabId);
|
const tabId = parseInt(currentTabId);
|
||||||
@@ -819,20 +816,17 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (sender.tab) {
|
if (sender.tab) {
|
||||||
getSettings().then(settings => {
|
const senderTabId = sender.tab.id;
|
||||||
const savedTargetId = parseInt(settings.targetTabId);
|
|
||||||
const senderTabId = sender.tab.id;
|
if (!currentTabId || currentTabId !== senderTabId) {
|
||||||
|
sendResponse({ status: 'ignored_unselected_tab' });
|
||||||
if (!savedTargetId || savedTargetId !== senderTabId) {
|
return;
|
||||||
sendResponse({ status: 'ignored_unselected_tab' });
|
}
|
||||||
return;
|
|
||||||
}
|
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
||||||
|
chrome.storage.session.set({ currentTabTitle });
|
||||||
currentTabId = senderTabId;
|
updateBadgeStatus();
|
||||||
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
processEvent();
|
||||||
updateBadgeStatus();
|
|
||||||
processEvent();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
routeToContent(message.action, message.payload);
|
routeToContent(message.action, message.payload);
|
||||||
processEvent();
|
processEvent();
|
||||||
@@ -861,21 +855,20 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'HEARTBEAT') {
|
} else if (message.type === 'HEARTBEAT') {
|
||||||
getSettings().then(settings => {
|
if (sender.tab) {
|
||||||
if (sender.tab) {
|
const senderTabId = sender.tab.id;
|
||||||
const savedTargetId = parseInt(settings.targetTabId);
|
|
||||||
const senderTabId = sender.tab.id;
|
if (!currentTabId || currentTabId !== senderTabId) {
|
||||||
|
sendResponse({ status: 'ignored_unselected_tab' });
|
||||||
if (!savedTargetId || savedTargetId !== senderTabId) {
|
return;
|
||||||
sendResponse({ status: 'ignored_unselected_tab' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentTabId = senderTabId;
|
|
||||||
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
|
||||||
updateBadgeStatus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
||||||
|
chrome.storage.session.set({ currentTabTitle });
|
||||||
|
updateBadgeStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSettings().then(settings => {
|
||||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
||||||
emit(EVENTS.PEER_STATUS, statusPayload);
|
emit(EVENTS.PEER_STATUS, statusPayload);
|
||||||
|
|
||||||
@@ -890,6 +883,22 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
} else if (message.type === 'SET_TARGET_TAB') {
|
||||||
|
currentTabId = message.tabId;
|
||||||
|
currentTabTitle = message.tabTitle;
|
||||||
|
chrome.storage.session.set({ currentTabId, currentTabTitle });
|
||||||
|
updateBadgeStatus();
|
||||||
|
|
||||||
|
if (currentTabId) {
|
||||||
|
chrome.scripting.executeScript({
|
||||||
|
target: { tabId: currentTabId },
|
||||||
|
files: ['content.js']
|
||||||
|
}).catch(err => {
|
||||||
|
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'LOG') {
|
} else if (message.type === 'LOG') {
|
||||||
addLog(`[Content] ${message.message}`, message.level || 'info');
|
addLog(`[Content] ${message.message}`, message.level || 'info');
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
@@ -904,11 +913,21 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
|||||||
if (tabId === currentTabId) {
|
if (tabId === currentTabId) {
|
||||||
currentTabId = null;
|
currentTabId = null;
|
||||||
currentTabTitle = null;
|
currentTabTitle = null;
|
||||||
chrome.storage.sync.set({ targetTabId: null });
|
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null });
|
||||||
updateBadgeStatus();
|
updateBadgeStatus();
|
||||||
addLog('Target tab closed.', 'warn');
|
addLog('Target tab closed.', 'warn');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-inject on full page refresh
|
||||||
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||||
|
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
|
||||||
|
chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
files: ['content.js']
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Initial Connect
|
// Initial Connect
|
||||||
connect();
|
connect();
|
||||||
|
|||||||
+22
-24
@@ -25,19 +25,16 @@
|
|||||||
PEER_STATUS: "peer_status"
|
PEER_STATUS: "peer_status"
|
||||||
};
|
};
|
||||||
|
|
||||||
let lastTargetState = null;
|
let expectedEvents = new Set();
|
||||||
let targetStateTimeout = null;
|
let expectedTimeouts = {};
|
||||||
|
|
||||||
function setTargetState(state) {
|
function expectEvent(state) {
|
||||||
lastTargetState = state;
|
expectedEvents.add(state);
|
||||||
if (targetStateTimeout) clearTimeout(targetStateTimeout);
|
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
|
||||||
if (state !== null) {
|
const timeout = state === 'seek' ? 10000 : 1500;
|
||||||
// Seek events might take longer than play/pause, using 2s for safety
|
expectedTimeouts[state] = setTimeout(() => {
|
||||||
const timeout = state === 'seek' ? 2000 : 1500;
|
expectedEvents.delete(state);
|
||||||
targetStateTimeout = setTimeout(() => {
|
}, timeout);
|
||||||
lastTargetState = null;
|
|
||||||
}, timeout);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reportLog(message, level = 'info') {
|
function reportLog(message, level = 'info') {
|
||||||
@@ -74,11 +71,11 @@
|
|||||||
if (ytButton) {
|
if (ytButton) {
|
||||||
const isCurrentlyPlaying = !video.paused;
|
const isCurrentlyPlaying = !video.paused;
|
||||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||||
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
|
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||||
ytButton.click();
|
ytButton.click();
|
||||||
}
|
}
|
||||||
if (action === EVENTS.SEEK) {
|
if (action === EVENTS.SEEK) {
|
||||||
setTargetState('seek');
|
expectEvent('seek');
|
||||||
video.currentTime = data.targetTime;
|
video.currentTime = data.targetTime;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -90,11 +87,11 @@
|
|||||||
if (twitchButton) {
|
if (twitchButton) {
|
||||||
const isCurrentlyPlaying = !video.paused;
|
const isCurrentlyPlaying = !video.paused;
|
||||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||||
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
|
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||||
twitchButton.click();
|
twitchButton.click();
|
||||||
}
|
}
|
||||||
if (action === EVENTS.SEEK) {
|
if (action === EVENTS.SEEK) {
|
||||||
setTargetState('seek');
|
expectEvent('seek');
|
||||||
video.currentTime = data.targetTime;
|
video.currentTime = data.targetTime;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -103,16 +100,16 @@
|
|||||||
|
|
||||||
// Fallback for native HTML5
|
// Fallback for native HTML5
|
||||||
if (action === EVENTS.PLAY) {
|
if (action === EVENTS.PLAY) {
|
||||||
setTargetState('playing');
|
expectEvent('playing');
|
||||||
video.play().catch((e) => {
|
video.play().catch((e) => {
|
||||||
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||||
setTargetState(null);
|
expectedEvents.delete('playing');
|
||||||
});
|
});
|
||||||
} else if (action === EVENTS.PAUSE) {
|
} else if (action === EVENTS.PAUSE) {
|
||||||
setTargetState('paused');
|
expectEvent('paused');
|
||||||
video.pause();
|
video.pause();
|
||||||
} else if (action === EVENTS.SEEK) {
|
} else if (action === EVENTS.SEEK) {
|
||||||
setTargetState('seek');
|
expectEvent('seek');
|
||||||
video.currentTime = data.targetTime;
|
video.currentTime = data.targetTime;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -135,7 +132,7 @@
|
|||||||
|
|
||||||
elapsed += interval;
|
elapsed += interval;
|
||||||
const timeDiff = Math.abs(video.currentTime - targetTime);
|
const timeDiff = Math.abs(video.currentTime - targetTime);
|
||||||
const ready = video.readyState >= 3 && timeDiff < 1.0;
|
const ready = video.readyState >= 3 && timeDiff < 2.0;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
@@ -175,7 +172,8 @@
|
|||||||
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
|
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTargetState('paused');
|
expectEvent('paused');
|
||||||
|
expectEvent('seek');
|
||||||
video.pause();
|
video.pause();
|
||||||
video.currentTime = payload.targetTime;
|
video.currentTime = payload.targetTime;
|
||||||
pollSeekReady(payload.targetTime).then((ready) => {
|
pollSeekReady(payload.targetTime).then((ready) => {
|
||||||
@@ -240,8 +238,8 @@
|
|||||||
|
|
||||||
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
|
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
|
||||||
|
|
||||||
if (eventState && lastTargetState === eventState) {
|
if (eventState && expectedEvents.has(eventState)) {
|
||||||
setTargetState(null); // Consume the match
|
expectedEvents.delete(eventState); // Consume the match
|
||||||
return; // Ignore event caused by our programmatic action
|
return; // Ignore event caused by our programmatic action
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-11
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "KoalaSync",
|
"name": "KoalaSync",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"description": "Synchronize video playback across different tabs and users.",
|
"description": "Synchronize video playback across different tabs and users.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
@@ -27,16 +27,6 @@
|
|||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
|
||||||
"matches": [
|
|
||||||
"<all_urls>"
|
|
||||||
],
|
|
||||||
"js": [
|
|
||||||
"content.js"
|
|
||||||
],
|
|
||||||
"run_at": "document_idle",
|
|
||||||
"all_frames": false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"matches": ["https://koalasync.shik3i.net/*"],
|
"matches": ["https://koalasync.shik3i.net/*"],
|
||||||
"js": ["bridge.js"],
|
"js": ["bridge.js"],
|
||||||
|
|||||||
+24
-13
@@ -49,7 +49,7 @@ let lastPeersJson = null;
|
|||||||
// --- Initialization ---
|
// --- Initialization ---
|
||||||
async function init() {
|
async function init() {
|
||||||
// Load Settings
|
// Load Settings
|
||||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId', 'filterNoise', 'username']);
|
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username']);
|
||||||
elements.serverUrl.value = data.serverUrl || '';
|
elements.serverUrl.value = data.serverUrl || '';
|
||||||
elements.roomId.value = data.roomId || '';
|
elements.roomId.value = data.roomId || '';
|
||||||
elements.password.value = data.password || '';
|
elements.password.value = data.password || '';
|
||||||
@@ -62,21 +62,23 @@ async function init() {
|
|||||||
setServerMode(false);
|
setServerMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate Tabs
|
|
||||||
await populateTabs();
|
|
||||||
|
|
||||||
toggleUIState(!!data.roomId);
|
toggleUIState(!!data.roomId);
|
||||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||||
refreshLogs();
|
refreshLogs();
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
|
|
||||||
// Initial Status Check
|
// Initial Status Check
|
||||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||||
if (res) {
|
if (res) {
|
||||||
localPeerId = res.peerId;
|
localPeerId = res.peerId;
|
||||||
applyConnectionStatus(res.status);
|
applyConnectionStatus(res.status);
|
||||||
updatePeerList(res.peers);
|
updatePeerList(res.peers);
|
||||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||||
|
|
||||||
|
// Populate Tabs using the background's targetTabId
|
||||||
|
await populateTabs(res.peers, res.targetTabId);
|
||||||
|
} else {
|
||||||
|
await populateTabs();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,10 +269,16 @@ function updatePeerList(peers) {
|
|||||||
populateTabs(peers);
|
populateTabs(peers);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function populateTabs(providedPeers = null) {
|
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||||
const data = await chrome.storage.sync.get(['targetTabId', 'filterNoise']);
|
const data = await chrome.storage.sync.get(['filterNoise']);
|
||||||
const isFilterActive = data.filterNoise !== false;
|
const isFilterActive = data.filterNoise !== false;
|
||||||
const currentTargetTabId = data.targetTabId;
|
|
||||||
|
// Fallback if not provided directly
|
||||||
|
let currentTargetTabId = providedTargetTabId;
|
||||||
|
if (currentTargetTabId === null) {
|
||||||
|
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||||
|
currentTargetTabId = status?.targetTabId;
|
||||||
|
}
|
||||||
|
|
||||||
// Use provided peers or fetch if missing
|
// Use provided peers or fetch if missing
|
||||||
let peerIds = providedPeers;
|
let peerIds = providedPeers;
|
||||||
@@ -646,15 +654,18 @@ elements.retryBtn.addEventListener('click', () => {
|
|||||||
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
|
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.targetTab.addEventListener('change', async () => {
|
elements.targetTab.addEventListener('change', () => {
|
||||||
await chrome.storage.sync.set({ targetTabId: elements.targetTab.value });
|
const val = elements.targetTab.value;
|
||||||
|
const tabId = val ? parseInt(val) : null;
|
||||||
|
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.text.replace('⭐ MATCH: ', '') || null;
|
||||||
|
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle });
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.forceSyncBtn.addEventListener('click', async () => {
|
elements.forceSyncBtn.addEventListener('click', async () => {
|
||||||
if (elements.forceSyncBtn.disabled) return;
|
if (elements.forceSyncBtn.disabled) return;
|
||||||
|
|
||||||
const settings = await chrome.storage.sync.get(['targetTabId']);
|
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||||
if (!settings.targetTabId) return;
|
if (!status || !status.targetTabId) return;
|
||||||
|
|
||||||
// Lockout to prevent spamming
|
// Lockout to prevent spamming
|
||||||
const originalText = elements.forceSyncBtn.textContent;
|
const originalText = elements.forceSyncBtn.textContent;
|
||||||
@@ -665,7 +676,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
|||||||
elements.forceSyncBtn.textContent = originalText;
|
elements.forceSyncBtn.textContent = originalText;
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
const tabId = parseInt(settings.targetTabId);
|
const tabId = parseInt(status.targetTabId);
|
||||||
|
|
||||||
const sendForceSync = (time) => {
|
const sendForceSync = (time) => {
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = "1.0.0";
|
export const PROTOCOL_VERSION = "1.0.0";
|
||||||
export const APP_VERSION = "1.1.1";
|
export const APP_VERSION = "1.1.2";
|
||||||
|
|
||||||
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
|
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
|
||||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
|
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
|
||||||
|
|||||||
Reference in New Issue
Block a user