mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2025-12-06 05:25:41 -05:00
* feat: add browser picker for opening URLs - Introduce a QML modal allowing users to select a web browser to open a given URL. - Add a CLI command `dms open <url>` that sends a `browser.open` request to the DMS server. - Implement server‑side Browser manager, request handling, and subscription handling to propagate open events to clients. - Extend router and server initialization to register the new “browser” capability and include it in advertised capabilities. - Expose `openUrlRequested` signal in DMSService.qml and connect it to the modal for seamless UI activation. - Add a desktop entry for the Browser Picker and update the active subscriptions list to include the browser service. * fix(browser-picker): resolve QML errors in BrowserPickerModal and DMSShell * fix(browser-picker): fix socket discovery in dms open command * feat: add keyboard navigation and dynamic model to browser picker - Replace the static browsers array with a ListModel built from AppSearchService, ensuring robust iteration and future‑proofing of the browser list. - Introduce keyboard navigation (arrow keys and Enter) using selectedIndex and gridColumns, allowing users to select a browser without a mouse. - Reset URL, selected index, and navigation flag when the modal closes to avoid stale state. - Redesign the grid layout to compute cell width from columns, improve focus handling, and use AppLauncherGridDelegate for a consistent UI. - Enhance delegate behavior to update selection on hover and reset keyboard navigation state appropriately. * feat: add searchable list/grid view to browser picker - Introduce view mode setting (list or grid) saved in SettingsData for persistent user preference - Add search field with real‑time filtering to quickly locate a browser by name - Sort browsers by usage frequency from AppUsageHistoryData, falling back to alphabetical order - Provide UI toggle buttons to switch between list and grid layouts, updating the stored setting - Adjust keyboard navigation logic to support both layouts and improve focus handling - Refine modal dimensions and header layout for better visual consistency - Record launched browser usage to keep usage rankings up‑to‑date. * feat(browser-picker): improve UX with search, view persistence, and usage tracking Enhance BrowserPickerModal to match AppLauncher design and functionality: UI/UX Improvements: - Add search bar with DankTextField for filtering browsers - Move view mode switcher (list/grid) to header next to title - Persist view mode preference to SettingsData.browserPickerViewMode - Match AppLauncher dimensions (520x500) - Add proper spacing between list items - Improve URL display with truncation (single line, elide middle) - Remove redundant close button Functionality: - Implement separate browser usage tracking in SettingsData.browserUsageHistory - Sort browsers by most recently used (independent from app launcher stats) - Add keyboard navigation auto-scrolling for list and grid views - Track usage count, last used timestamp, and browser name - Filter browsers by search query Technical: - Add ensureVisible() functions to DankListView and DankGridView - Store browser usage with count, lastUsed, and name fields - Update browser list reactively on search query changes * feat(browser-picker): use appLauncherGridColumns setting for grid layout Make browser picker grid view respect the same column setting as the app launcher for consistent UI across both components. * refactor: make browser picker extensible for any MIME type/category Refactor browser picker into a generic, reusable application picker system that can handle any MIME type or application category, similar to Junction. This addresses the maintainer feedback about making the functionality "as re-usable as possible." Frontend (QML): - Create generic AppPickerModal component (~450 lines) - Configurable filtering by application categories - Customizable title, view modes, and usage tracking - Emits applicationSelected signal for flexibility - Refactor BrowserPickerModal as thin wrapper (473 → 46 lines) - Demonstrates how to create specialized pickers - Maintains all existing browser picker functionality Backend (Go): - Rename browser package to apppicker for clarity - Enhance event model to support: - MIME types (for future file associations) - Application categories (WebBrowser, Office, Graphics, etc.) - Request types (url, file, custom) - Maintain backward compatibility with browser.open method - Add new apppicker.open method for generic usage CLI: - Rename commands_browser.go to commands_open.go - Add extensibility flags: --mime/-m: Filter by MIME type --category/-c: Filter by category (repeatable) --type/-t: Specify request type - Examples: dms open file.pdf --category Office dms open image.png --category Graphics DMSService: - Add appPickerRequested signal for generic events - Smart routing between URL and generic app picker events - Fully backward compatible Benefits: - Easy to create new pickers (~15 lines of wrapper code) - Foundation for universal file handling system - Consistent UX across all picker types - Ready for MIME type associations Future extensions: - PDF picker, image viewer picker, text editor picker - Default application management - File association UI in settings - Multi-MIME type desktop file integration * fix(cli): remove all shorthands from open command flags for consistency Remove shorthands from --mime, --category, and --type flags to maintain consistency and avoid conflicts with global flags. Flags now (all long-form only): - --category: Application categories - --mime: MIME type - --type: Request type Global flags still available: - --config, -c: Config directory path * style: apply gofmt formatting to apppicker files Fix formatting issues caught by CI: - Align struct field spacing in OpenEvent - Align variable declaration spacing - Fix Args field alignment in cobra.Command * feat(apppicker): add generic file opener with auto MIME detection Implements Junction-style generic file opening capabilities: **Backend (Go):** - Enhanced CLI to parse file:// URIs and extract file paths - Auto-detect MIME types from file extensions using Go's mime package - Auto-map MIME types to desktop categories: - Images → Graphics, Viewer - Videos → Video, AudioVideo - Audio → Audio, AudioVideo - Text → TextEditor, Office (or WebBrowser for HTML) - PDFs → Office, Viewer - Office docs → Office - Archives → Archiving, Utility - Added debug logging to CLI and server handler for troubleshooting **Frontend (QML):** - Added generic AppPickerModal (filePickerModal) for file selection - Connected to DMSService.appPickerRequested signal - Implemented onApplicationSelected handler with desktop entry field code support: - %f/%F for file paths - %u/%U for file:// URIs - Fallback to appending path if no field codes - Separate usage tracking: filePickerUsageHistory **Desktop Integration:** - Updated dms-open.desktop to handle x-scheme-handler/file - Changed category from Network;WebBrowser to Utility (more generic) - Added text/html to MIME types **Usage:** Set DMS as default for specific MIME types in ~/.config/mimeapps.list: text/plain=dms-open.desktop image/png=dms-open.desktop application/pdf=dms-open.desktop Then use: xdg-open file.txt xdg-open image.png dms open document.pdf The picker will show appropriate apps based on auto-detected categories. Related to #815 * fix: resolve relative path handling by converting to absolute paths - Convert file:// URIs to absolute filesystem paths for reliable file resolution - Convert plain local file arguments to absolute paths to ensure consistent processing - Update log messages to display absolute paths, improving traceability - Retain request type detection while using absolute path extensions for MIME type inference * feat(app-picker): add Tab key view toggle and fix targetData binding - Add Tab key to toggle between grid and list views for better keyboard UX - Fix bug where targetData binding broke after first modal close - Removed targetData reset from onDialogClosed - Parent components (BrowserPickerModal, filePickerModal) now manage targetData - Fixes issue where URL/file path disappeared on subsequent opens * fix(app-picker): properly escape URLs and file paths for shell execution - Add shellEscape() function to wrap arguments in single quotes - Prevents shell interpretation of special characters (&, ?, =, spaces, etc.) - Fixes bug where URLs with query parameters were truncated at first & - Example: http://localhost:36275/vnc.html?autoconnect=true&reconnect=true now properly passes the full URL instead of cutting at first & - Applied to both BrowserPickerModal (URLs) and filePickerModal (file paths) * fix: check error return from InitializeAppPickerManager
554 lines
16 KiB
QML
554 lines
16 KiB
QML
pragma Singleton
|
|
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Common
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property bool dmsAvailable: false
|
|
property var capabilities: []
|
|
property int apiVersion: 0
|
|
property string cliVersion: ""
|
|
readonly property int expectedApiVersion: 1
|
|
property var availablePlugins: []
|
|
property var installedPlugins: []
|
|
property bool isConnected: false
|
|
property bool isConnecting: false
|
|
property bool subscribeConnected: false
|
|
readonly property bool forceExtWorkspace: false
|
|
|
|
readonly property string socketPath: Quickshell.env("DMS_SOCKET")
|
|
|
|
property var pendingRequests: ({})
|
|
property int requestIdCounter: 0
|
|
property bool shownOutdatedError: false
|
|
property string updateCommand: "dms update"
|
|
property bool checkingUpdateCommand: false
|
|
|
|
signal pluginsListReceived(var plugins)
|
|
signal installedPluginsReceived(var plugins)
|
|
signal searchResultsReceived(var plugins)
|
|
signal operationSuccess(string message)
|
|
signal operationError(string error)
|
|
signal connectionStateChanged
|
|
|
|
signal networkStateUpdate(var data)
|
|
signal cupsStateUpdate(var data)
|
|
signal loginctlStateUpdate(var data)
|
|
signal loginctlEvent(var event)
|
|
signal capabilitiesReceived
|
|
signal credentialsRequest(var data)
|
|
signal bluetoothPairingRequest(var data)
|
|
signal dwlStateUpdate(var data)
|
|
signal brightnessStateUpdate(var data)
|
|
signal brightnessDeviceUpdate(var device)
|
|
signal extWorkspaceStateUpdate(var data)
|
|
signal wlrOutputStateUpdate(var data)
|
|
signal evdevStateUpdate(var data)
|
|
signal openUrlRequested(string url)
|
|
signal appPickerRequested(var data)
|
|
|
|
property bool capsLockState: false
|
|
|
|
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser"]
|
|
|
|
Component.onCompleted: {
|
|
if (socketPath && socketPath.length > 0) {
|
|
detectUpdateCommand();
|
|
}
|
|
}
|
|
|
|
function detectUpdateCommand() {
|
|
checkingUpdateCommand = true;
|
|
checkAurHelper.running = true;
|
|
}
|
|
|
|
function startSocketConnection() {
|
|
if (socketPath && socketPath.length > 0) {
|
|
testProcess.running = true;
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: checkAurHelper
|
|
command: ["sh", "-c", "command -v paru || command -v yay"]
|
|
running: false
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const helper = text.trim();
|
|
if (helper.includes("paru")) {
|
|
checkDmsPackage.helper = "paru";
|
|
checkDmsPackage.running = true;
|
|
} else if (helper.includes("yay")) {
|
|
checkDmsPackage.helper = "yay";
|
|
checkDmsPackage.running = true;
|
|
} else {
|
|
updateCommand = "dms update";
|
|
checkingUpdateCommand = false;
|
|
startSocketConnection();
|
|
}
|
|
}
|
|
}
|
|
|
|
onExited: exitCode => {
|
|
if (exitCode !== 0) {
|
|
updateCommand = "dms update";
|
|
checkingUpdateCommand = false;
|
|
startSocketConnection();
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: checkDmsPackage
|
|
property string helper: ""
|
|
command: ["sh", "-c", "pacman -Qi dms-shell-git 2>/dev/null || pacman -Qi dms-shell-bin 2>/dev/null"]
|
|
running: false
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
if (text.includes("dms-shell-git")) {
|
|
updateCommand = checkDmsPackage.helper + " -S dms-shell-git";
|
|
} else if (text.includes("dms-shell-bin")) {
|
|
updateCommand = checkDmsPackage.helper + " -S dms-shell-bin";
|
|
} else {
|
|
updateCommand = "dms update";
|
|
}
|
|
checkingUpdateCommand = false;
|
|
startSocketConnection();
|
|
}
|
|
}
|
|
|
|
onExited: exitCode => {
|
|
if (exitCode !== 0) {
|
|
updateCommand = "dms update";
|
|
checkingUpdateCommand = false;
|
|
startSocketConnection();
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: testProcess
|
|
command: ["test", "-S", root.socketPath]
|
|
|
|
onExited: exitCode => {
|
|
if (exitCode === 0) {
|
|
root.dmsAvailable = true;
|
|
connectSocket();
|
|
} else {
|
|
root.dmsAvailable = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function connectSocket() {
|
|
if (!dmsAvailable || isConnected || isConnecting) {
|
|
return;
|
|
}
|
|
|
|
isConnecting = true;
|
|
requestSocket.connected = true;
|
|
}
|
|
|
|
DankSocket {
|
|
id: requestSocket
|
|
path: root.socketPath
|
|
connected: false
|
|
|
|
onConnectionStateChanged: {
|
|
if (connected) {
|
|
root.isConnected = true;
|
|
root.isConnecting = false;
|
|
root.connectionStateChanged();
|
|
subscribeSocket.connected = true;
|
|
} else {
|
|
root.isConnected = false;
|
|
root.isConnecting = false;
|
|
root.apiVersion = 0;
|
|
root.capabilities = [];
|
|
root.connectionStateChanged();
|
|
}
|
|
}
|
|
|
|
parser: SplitParser {
|
|
onRead: line => {
|
|
if (!line || line.length === 0) {
|
|
return;
|
|
}
|
|
|
|
console.log("DMSService: Request socket <<", line);
|
|
|
|
try {
|
|
const response = JSON.parse(line);
|
|
handleResponse(response);
|
|
} catch (e) {
|
|
console.warn("DMSService: Failed to parse request response:", line, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
DankSocket {
|
|
id: subscribeSocket
|
|
path: root.socketPath
|
|
connected: false
|
|
|
|
onConnectionStateChanged: {
|
|
root.subscribeConnected = connected;
|
|
if (connected) {
|
|
sendSubscribeRequest();
|
|
}
|
|
}
|
|
|
|
parser: SplitParser {
|
|
onRead: line => {
|
|
if (!line || line.length === 0) {
|
|
return;
|
|
}
|
|
|
|
console.log("DMSService: Subscribe socket <<", line);
|
|
|
|
try {
|
|
const response = JSON.parse(line);
|
|
handleSubscriptionEvent(response);
|
|
} catch (e) {
|
|
console.warn("DMSService: Failed to parse subscription event:", line, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function sendSubscribeRequest() {
|
|
const request = {
|
|
"method": "subscribe"
|
|
};
|
|
|
|
if (activeSubscriptions.length > 0) {
|
|
request.params = {
|
|
"services": activeSubscriptions
|
|
};
|
|
console.log("DMSService: Subscribing to services:", JSON.stringify(activeSubscriptions));
|
|
} else {
|
|
console.log("DMSService: Subscribing to all services");
|
|
}
|
|
|
|
subscribeSocket.send(request);
|
|
}
|
|
|
|
function subscribe(services) {
|
|
if (!Array.isArray(services)) {
|
|
services = [services];
|
|
}
|
|
|
|
activeSubscriptions = services;
|
|
|
|
if (subscribeConnected) {
|
|
subscribeSocket.connected = false;
|
|
Qt.callLater(() => {
|
|
subscribeSocket.connected = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
function addSubscription(service) {
|
|
if (activeSubscriptions.includes("all"))
|
|
return;
|
|
if (!activeSubscriptions.includes(service)) {
|
|
const newSubs = [...activeSubscriptions, service];
|
|
subscribe(newSubs);
|
|
}
|
|
}
|
|
|
|
function removeSubscription(service) {
|
|
if (activeSubscriptions.includes("all")) {
|
|
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser"]
|
|
const filtered = allServices.filter(s => s !== service)
|
|
subscribe(filtered)
|
|
} else {
|
|
const filtered = activeSubscriptions.filter(s => s !== service);
|
|
if (filtered.length === 0) {
|
|
console.warn("DMSService: Cannot remove last subscription");
|
|
return;
|
|
}
|
|
subscribe(filtered);
|
|
}
|
|
}
|
|
|
|
function subscribeAll() {
|
|
subscribe(["all"]);
|
|
}
|
|
|
|
function subscribeAllExcept(excludeServices) {
|
|
if (!Array.isArray(excludeServices)) {
|
|
excludeServices = [excludeServices];
|
|
}
|
|
|
|
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser"]
|
|
const filtered = allServices.filter(s => !excludeServices.includes(s))
|
|
subscribe(filtered)
|
|
}
|
|
|
|
function handleSubscriptionEvent(response) {
|
|
if (response.error) {
|
|
if (response.error.includes("unknown method") && response.error.includes("subscribe")) {
|
|
if (!shownOutdatedError) {
|
|
console.error("DMSService: Server does not support subscribe method");
|
|
ToastService.showError(I18n.tr("DMS out of date"), I18n.tr("To update, run the following command:"), updateCommand);
|
|
shownOutdatedError = true;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!response.result) {
|
|
return;
|
|
}
|
|
|
|
const service = response.result.service;
|
|
const data = response.result.data;
|
|
|
|
if (service === "server") {
|
|
apiVersion = data.apiVersion || 0;
|
|
cliVersion = data.cliVersion || "";
|
|
capabilities = data.capabilities || [];
|
|
|
|
console.info("DMSService: Connected (API v" + apiVersion + ", CLI " + cliVersion + ") -", JSON.stringify(capabilities));
|
|
|
|
if (apiVersion < expectedApiVersion) {
|
|
ToastService.showError("DMS server is outdated (API v" + apiVersion + ", expected v" + expectedApiVersion + ")");
|
|
}
|
|
|
|
capabilitiesReceived();
|
|
} else if (service === "network") {
|
|
networkStateUpdate(data);
|
|
} else if (service === "network.credentials") {
|
|
credentialsRequest(data);
|
|
} else if (service === "loginctl") {
|
|
if (data.event) {
|
|
loginctlEvent(data);
|
|
} else {
|
|
loginctlStateUpdate(data);
|
|
}
|
|
} else if (service === "bluetooth.pairing") {
|
|
bluetoothPairingRequest(data);
|
|
} else if (service === "cups") {
|
|
cupsStateUpdate(data);
|
|
} else if (service === "dwl") {
|
|
dwlStateUpdate(data);
|
|
} else if (service === "brightness") {
|
|
brightnessStateUpdate(data);
|
|
} else if (service === "brightness.update") {
|
|
if (data.device) {
|
|
brightnessDeviceUpdate(data.device);
|
|
}
|
|
} else if (service === "extworkspace") {
|
|
extWorkspaceStateUpdate(data);
|
|
} else if (service === "wlroutput") {
|
|
wlrOutputStateUpdate(data);
|
|
} else if (service === "evdev") {
|
|
if (data.capsLock !== undefined) {
|
|
capsLockState = data.capsLock;
|
|
}
|
|
evdevStateUpdate(data)
|
|
} else if (service === "browser.open_requested") {
|
|
if (data.target) {
|
|
if (data.requestType === "url" || !data.requestType) {
|
|
openUrlRequested(data.target)
|
|
} else {
|
|
appPickerRequested(data)
|
|
}
|
|
} else if (data.url) {
|
|
openUrlRequested(data.url)
|
|
}
|
|
}
|
|
}
|
|
|
|
function sendRequest(method, params, callback) {
|
|
if (!isConnected) {
|
|
console.warn("DMSService.sendRequest: Not connected, method:", method);
|
|
if (callback) {
|
|
callback({
|
|
"error": "not connected to DMS socket"
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
requestIdCounter++;
|
|
const id = Date.now() + requestIdCounter;
|
|
const request = {
|
|
"id": id,
|
|
"method": method
|
|
};
|
|
|
|
if (params) {
|
|
request.params = params;
|
|
}
|
|
|
|
if (callback) {
|
|
pendingRequests[id] = callback;
|
|
}
|
|
|
|
console.log("DMSService.sendRequest: Sending request id=" + id + " method=" + method);
|
|
requestSocket.send(request);
|
|
}
|
|
|
|
function handleResponse(response) {
|
|
const callback = pendingRequests[response.id];
|
|
|
|
if (callback) {
|
|
delete pendingRequests[response.id];
|
|
callback(response);
|
|
}
|
|
}
|
|
|
|
function ping(callback) {
|
|
sendRequest("ping", null, callback);
|
|
}
|
|
|
|
function listPlugins(callback) {
|
|
sendRequest("plugins.list", null, response => {
|
|
if (response.result) {
|
|
availablePlugins = response.result;
|
|
pluginsListReceived(response.result);
|
|
}
|
|
if (callback) {
|
|
callback(response);
|
|
}
|
|
});
|
|
}
|
|
|
|
function listInstalled(callback) {
|
|
sendRequest("plugins.listInstalled", null, response => {
|
|
if (response.result) {
|
|
installedPlugins = response.result;
|
|
installedPluginsReceived(response.result);
|
|
}
|
|
if (callback) {
|
|
callback(response);
|
|
}
|
|
});
|
|
}
|
|
|
|
function search(query, category, compositor, capability, callback) {
|
|
const params = {
|
|
"query": query
|
|
};
|
|
if (category) {
|
|
params.category = category;
|
|
}
|
|
if (compositor) {
|
|
params.compositor = compositor;
|
|
}
|
|
if (capability) {
|
|
params.capability = capability;
|
|
}
|
|
|
|
sendRequest("plugins.search", params, response => {
|
|
if (response.result) {
|
|
searchResultsReceived(response.result);
|
|
}
|
|
if (callback) {
|
|
callback(response);
|
|
}
|
|
});
|
|
}
|
|
|
|
function install(pluginName, callback) {
|
|
sendRequest("plugins.install", {
|
|
"name": pluginName
|
|
}, response => {
|
|
if (callback) {
|
|
callback(response);
|
|
}
|
|
if (!response.error) {
|
|
listInstalled();
|
|
}
|
|
});
|
|
}
|
|
|
|
function uninstall(pluginName, callback) {
|
|
sendRequest("plugins.uninstall", {
|
|
"name": pluginName
|
|
}, response => {
|
|
if (callback) {
|
|
callback(response);
|
|
}
|
|
if (!response.error) {
|
|
listInstalled();
|
|
}
|
|
});
|
|
}
|
|
|
|
function update(pluginName, callback) {
|
|
sendRequest("plugins.update", {
|
|
"name": pluginName
|
|
}, response => {
|
|
if (callback) {
|
|
callback(response);
|
|
}
|
|
if (!response.error) {
|
|
listInstalled();
|
|
}
|
|
});
|
|
}
|
|
|
|
function lockSession(callback) {
|
|
sendRequest("loginctl.lock", null, callback);
|
|
}
|
|
|
|
function unlockSession(callback) {
|
|
sendRequest("loginctl.unlock", null, callback);
|
|
}
|
|
|
|
function bluetoothPair(devicePath, callback) {
|
|
sendRequest("bluetooth.pair", {
|
|
"device": devicePath
|
|
}, callback);
|
|
}
|
|
|
|
function bluetoothConnect(devicePath, callback) {
|
|
sendRequest("bluetooth.connect", {
|
|
"device": devicePath
|
|
}, callback);
|
|
}
|
|
|
|
function bluetoothDisconnect(devicePath, callback) {
|
|
sendRequest("bluetooth.disconnect", {
|
|
"device": devicePath
|
|
}, callback);
|
|
}
|
|
|
|
function bluetoothRemove(devicePath, callback) {
|
|
sendRequest("bluetooth.remove", {
|
|
"device": devicePath
|
|
}, callback);
|
|
}
|
|
|
|
function bluetoothTrust(devicePath, callback) {
|
|
sendRequest("bluetooth.trust", {
|
|
"device": devicePath
|
|
}, callback);
|
|
}
|
|
|
|
function bluetoothSubmitPairing(token, secrets, accept, callback) {
|
|
sendRequest("bluetooth.pairing.submit", {
|
|
"token": token,
|
|
"secrets": secrets,
|
|
"accept": accept
|
|
}, callback);
|
|
}
|
|
|
|
function bluetoothCancelPairing(token, callback) {
|
|
sendRequest("bluetooth.pairing.cancel", {
|
|
"token": token
|
|
}, callback);
|
|
}
|
|
}
|