Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ build:
--extra-source=icons/ \
--extra-source=tailscale.js \
--extra-source=timeout.js \
--extra-source=compat.js; \
--extra-source=compat.js \
--extra-source=stylesheet.css; \
mv $(EXTENSION_DIR).shell-extension.zip ../$(BUNDLE_PATH)

install:
Expand Down
162 changes: 134 additions & 28 deletions [email protected]/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const QuickSettingsMenu = Main.panel.statusArea.quickSettings;
import { Tailscale } from "./tailscale.js";
import { clearInterval, clearSources, setInterval } from "./timeout.js";

const MAX_SCROLL_VIEW_HEIGHT = 300;

const TailscaleIndicator = GObject.registerClass(
class TailscaleIndicator extends QuickSettings.SystemIndicator {
_init(icon, tailscale) {
Expand All @@ -60,6 +62,8 @@ const TailscaleDeviceItem = GObject.registerClass(
_init(icon_name, text, subtitle, onClick, onLongClick) {
super._init({
activate: onClick,
reactive: true,
can_focus: true,
});

const icon = new St.Icon({
Expand Down Expand Up @@ -140,41 +144,114 @@ const TailscaleMenuToggle = GObject.registerClass(

// NODES
const nodes = new PopupMenu.PopupMenuSection();
const scrollView = new St.ScrollView({
style_class: 'vfade',
hscrollbar_policy: St.PolicyType.NEVER,
vscrollbar_policy: St.PolicyType.AUTOMATIC,
x_expand: true,
y_expand: true,
clip_to_allocation: true,
});
scrollView.add_child(nodes.actor);
const scrollMenuItem = new PopupMenu.PopupBaseMenuItem({
reactive: false,
can_focus: false
});
scrollMenuItem.add_child(scrollView);
const update_nodes = (obj) => {
nodes.removeAll();
const mullvad = new PopupMenu.PopupSubMenuMenuItem("Mullvad", false, {});
for (const node of obj.nodes) {
const menu = (node.mullvad && !node.exit_node) ? mullvad.menu : nodes;
const device_icon = !node.online
? "network-offline-symbolic"
: ((node.os == "android" || node.os == "iOS")
? "phone-symbolic"
: (node.mullvad
? "network-vpn-symbolic"
: "computer-symbolic"));

// Prepare menu sections for non-Mullvad and Mullvad nodes
const nonMullvadSection = new PopupMenu.PopupMenuSection();
const mullvadSection = new PopupMenu.PopupMenuSection();
mullvadSection.actor.add_style_class_name('country-sub-menu');

const nonMullvadNodes = obj.nodes.filter(node => !node.mullvad);
const mullvadNodes = obj.nodes.filter(node => node.mullvad);
const mullvadExitNode = mullvadNodes.find(node => node.exit_node);

// Treat Mullvad Exit Node differently
if (mullvadExitNode) {
const exitMenuItem = createTailscaleDeviceItem(
mullvadExitNode,
`${mullvadExitNode.location.Country} (${mullvadExitNode.name})`,
_("disable exit node"),
'network-vpn-symbolic',
tailscale
);
mullvadSection.addMenuItem(exitMenuItem);
}

// If there are mullvad nodes, group them by country
if (mullvadNodes.length > 0) {
const mullvadByCountry = mullvadNodes.reduce((acc, node) => {
const country = node.location.Country;
if (!acc[country]) acc[country] = [];
acc[country].push(node);
return acc;
}, {});

// Sort countries and create menu items
Object.keys(mullvadByCountry).sort().forEach(country => {
const countryNodes = mullvadByCountry[country];
if (countryNodes.length === 1) {
const node = countryNodes[0];
if (!node.exit_node) {
const menuItem = createTailscaleDeviceItem(
node,
`${node.location.Country} (${node.name})`,
node.exit_node ? _("disable exit node") : node.online ? _("use as exit node") : _("offline"),
'network-vpn-symbolic',
tailscale
);
mullvadSection.addMenuItem(menuItem);
}
} else {
const countrySubMenu = new PopupMenu.PopupSubMenuMenuItem(country, false, {});
countryNodes.forEach(node => {
const menuItem = createTailscaleDeviceItem(
node,
`${node.location.City} (${node.name})`,
node.exit_node ? _("disable exit node") : node.online ? _("use as exit node") : _("offline"),
'network-vpn-symbolic',
tailscale
);
countrySubMenu.menu.addMenuItem(menuItem);
});
mullvadSection.addMenuItem(countrySubMenu);
}
});
}

// Add non-Mullvad nodes to the nonMullvadSection
nonMullvadNodes.forEach(node => {
const deviceIcon = node.online
? (node.os === "android" || node.os === "iOS" ? "phone-symbolic" : "computer-symbolic")
: "network-offline-symbolic";
const subtitle = node.exit_node ? _("disable exit node") : (node.exit_node_option ? _("use as exit node") : "");
const onClick = node.exit_node_option ? () => { tailscale.exit_node = node.exit_node ? "" : node.id; } : null;
const onLongClick = () => {
if (!node.ips)
return false;

St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, node.ips[0]);
St.Clipboard.get_default().set_text(St.ClipboardType.PRIMARY, node.ips[0]);
Main.osdWindowManager.show(-1, icon, _("IP address has been copied to the clipboard"));
return true;
};

menu.addMenuItem(new TailscaleDeviceItem(device_icon, node.name, subtitle, onClick, onLongClick));
const item = createTailscaleDeviceItem(node, node.name, subtitle, deviceIcon, tailscale);
nonMullvadSection.addMenuItem(item);
});

// Add sections
if (nonMullvadNodes.length > 0) {
const title = new PopupMenu.PopupMenuItem(_("Nodes"), {});
nodes.addMenuItem(title);
nodes.addMenuItem(nonMullvadSection);
}
if (mullvad.menu.isEmpty()) {
mullvad.destroy();
} else {
nodes.addMenuItem(mullvad);
if (mullvadNodes.length > 0) {
nodes.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
const title = new PopupMenu.PopupMenuItem(_("Mullvad Exit Nodes"), {});
nodes.addMenuItem(title);
nodes.addMenuItem(mullvadSection);
}
}

this.menu.addMenuItem(scrollMenuItem);

applyScrollViewHeight(nodes, scrollView);
};
tailscale.connect("notify::nodes", (obj) => update_nodes(obj));
update_nodes(tailscale);
this.menu.addMenuItem(nodes);

// SEPARATOR
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
Expand Down Expand Up @@ -255,5 +332,34 @@ export default class TailscaleExtension extends Extension {

function init(meta) {
ExtensionUtils.initTranslations(Me.metadata.uuid);
Main.loadTheme();
return new TailscaleExtension(meta.uuid, Me.path);
}

function createTailscaleDeviceItem(node, primaryText, secondaryText, iconName, tailscale) {
const onClick = node.exit_node_option ? () => { tailscale.exit_node = node.exit_node ? "" : node.id; } : null;
const onLongClick = () => {
if (!node.ips)
return false;

const gicon = Gio.Icon.new_for_string(iconName);
St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, node.ips[0]);
St.Clipboard.get_default().set_text(St.ClipboardType.PRIMARY, node.ips[0]);
Main.osdWindowManager.show(-1, gicon, _("IP address has been copied to the clipboard"));
return true;
};

const item = new TailscaleDeviceItem(iconName, primaryText, secondaryText, onClick, onLongClick);
return item;
}

function applyScrollViewHeight(nodes, scrollView) {
let totalHeight = 0;
nodes.actor.get_children().forEach(section => {
section.get_children().forEach(item => {
let [minHeight, naturalHeight] = item.get_preferred_height(-1);
totalHeight += naturalHeight;
});
});
scrollView.set_height(Math.min(totalHeight, MAX_SCROLL_VIEW_HEIGHT));
}
7 changes: 7 additions & 0 deletions [email protected]/stylesheet.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.country-sub-menu .popup-menu-item {
background-color: transparent;
}

.country-sub-menu .popup-menu-item:hover {
background-color: #4c4c4c;
}