updated control access

This commit is contained in:
Quality System Admin
2025-10-16 02:36:32 +03:00
parent 50c791e242
commit c96039542d
266 changed files with 32656 additions and 9 deletions

View File

@@ -83,3 +83,34 @@
[2025-10-16 00:06:02 +0300] [288324] [INFO] Worker exiting (pid: 288324)
[2025-10-16 00:06:02 +0300] [288325] [INFO] Worker exiting (pid: 288325)
[2025-10-16 00:06:03 +0300] [288316] [INFO] Shutting down: Master
[2025-10-16 02:34:31 +0300] [299414] [INFO] Starting gunicorn 23.0.0
[2025-10-16 02:34:31 +0300] [299414] [INFO] Listening at: http://0.0.0.0:8781 (299414)
[2025-10-16 02:34:31 +0300] [299414] [INFO] Using worker: sync
[2025-10-16 02:34:31 +0300] [299414] [INFO] Trasabilitate Application server is ready. Listening on: [('0.0.0.0', 8781)]
[2025-10-16 02:34:31 +0300] [299414] [INFO] Worker spawned (pid: [booting])
[2025-10-16 02:34:31 +0300] [299432] [INFO] Booting worker with pid: 299432
[2025-10-16 02:34:31 +0300] [299432] [INFO] Worker spawned (pid: 299432)
[2025-10-16 02:34:31 +0300] [299414] [INFO] Worker spawned (pid: [booting])
[2025-10-16 02:34:31 +0300] [299438] [INFO] Booting worker with pid: 299438
[2025-10-16 02:34:31 +0300] [299438] [INFO] Worker spawned (pid: 299438)
[2025-10-16 02:34:32 +0300] [299414] [INFO] Worker spawned (pid: [booting])
[2025-10-16 02:34:32 +0300] [299439] [INFO] Booting worker with pid: 299439
[2025-10-16 02:34:32 +0300] [299439] [INFO] Worker spawned (pid: 299439)
[2025-10-16 02:34:32 +0300] [299414] [INFO] Worker spawned (pid: [booting])
[2025-10-16 02:34:32 +0300] [299440] [INFO] Booting worker with pid: 299440
[2025-10-16 02:34:32 +0300] [299440] [INFO] Worker spawned (pid: 299440)
[2025-10-16 02:34:32 +0300] [299414] [INFO] Worker spawned (pid: [booting])
[2025-10-16 02:34:32 +0300] [299441] [INFO] Booting worker with pid: 299441
[2025-10-16 02:34:32 +0300] [299441] [INFO] Worker spawned (pid: 299441)
[2025-10-16 02:34:32 +0300] [299414] [INFO] Worker spawned (pid: [booting])
[2025-10-16 02:34:32 +0300] [299442] [INFO] Booting worker with pid: 299442
[2025-10-16 02:34:32 +0300] [299442] [INFO] Worker spawned (pid: 299442)
[2025-10-16 02:34:32 +0300] [299414] [INFO] Worker spawned (pid: [booting])
[2025-10-16 02:34:32 +0300] [299443] [INFO] Booting worker with pid: 299443
[2025-10-16 02:34:32 +0300] [299443] [INFO] Worker spawned (pid: 299443)
[2025-10-16 02:34:32 +0300] [299414] [INFO] Worker spawned (pid: [booting])
[2025-10-16 02:34:32 +0300] [299444] [INFO] Booting worker with pid: 299444
[2025-10-16 02:34:32 +0300] [299444] [INFO] Worker spawned (pid: 299444)
[2025-10-16 02:34:32 +0300] [299414] [INFO] Worker spawned (pid: [booting])
[2025-10-16 02:34:32 +0300] [299445] [INFO] Booting worker with pid: 299445
[2025-10-16 02:34:32 +0300] [299445] [INFO] Worker spawned (pid: 299445)

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Script to add modules column to external database and migrate existing users
"""
import os
import sys
import mariadb
def migrate_external_database():
"""Add modules column to external database and update existing users"""
try:
# Read external database configuration from instance folder
config_file = os.path.join(os.path.dirname(__file__), 'instance/external_server.conf')
if not os.path.exists(config_file):
print("External database configuration file not found at instance/external_server.conf")
return False
with open(config_file, 'r') as f:
lines = f.read().strip().split('\n')
# Parse the config file format "key=value"
config = {}
for line in lines:
if '=' in line and not line.strip().startswith('#'):
key, value = line.split('=', 1)
config[key.strip()] = value.strip()
host = config.get('server_domain', 'localhost')
port = int(config.get('port', '3306'))
database = config.get('database_name', '')
user = config.get('username', '')
password = config.get('password', '')
if not all([host, database, user, password]):
print("Missing required database configuration values.")
return False
print(f"Connecting to external database: {host}:{port}/{database}")
# Connect to external database
conn = mariadb.connect(
user=user,
password=password,
host=host,
port=port,
database=database
)
cursor = conn.cursor()
# Check if users table exists
cursor.execute("SHOW TABLES LIKE 'users'")
if not cursor.fetchone():
print("Users table not found in external database.")
conn.close()
return False
# Check if modules column already exists
cursor.execute("DESCRIBE users")
columns = [row[0] for row in cursor.fetchall()]
if 'modules' not in columns:
print("Adding modules column to users table...")
cursor.execute("ALTER TABLE users ADD COLUMN modules TEXT")
print("Modules column added successfully.")
else:
print("Modules column already exists.")
# Get current users and convert their roles
cursor.execute("SELECT id, username, role FROM users")
users = cursor.fetchall()
role_mapping = {
'superadmin': ('superadmin', None),
'administrator': ('admin', None),
'admin': ('admin', None),
'quality': ('manager', '["quality"]'),
'warehouse': ('manager', '["warehouse"]'),
'warehouse_manager': ('manager', '["warehouse"]'),
'scan': ('worker', '["quality"]'),
'etichete': ('manager', '["labels"]'),
'quality_manager': ('manager', '["quality"]'),
'quality_worker': ('worker', '["quality"]'),
}
print(f"Migrating {len(users)} users...")
for user_id, username, old_role in users:
if old_role in role_mapping:
new_role, modules_json = role_mapping[old_role]
cursor.execute("UPDATE users SET role = ?, modules = ? WHERE id = ?",
(new_role, modules_json, user_id))
print(f" {username}: {old_role} -> {new_role} with modules {modules_json}")
else:
print(f" {username}: Unknown role '{old_role}', keeping as-is")
conn.commit()
conn.close()
print("External database migration completed successfully!")
return True
except Exception as e:
print(f"Error migrating external database: {e}")
return False
if __name__ == "__main__":
print("External Database Migration for Simplified 4-Tier Permission System")
print("=" * 70)
success = migrate_external_database()
if success:
print("\n✅ Migration completed successfully!")
print("\nUsers can now log in with the new simplified permission system.")
print("Role structure: superadmin → admin → manager → worker")
print("Modules: quality, warehouse, labels")
else:
print("\n❌ Migration failed. Please check the error messages above.")

172
old code/migrate_permissions.py Executable file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
Migration script to convert from complex permission system to simplified 4-tier system
This script will:
1. Add 'modules' column to users table
2. Convert existing roles to new 4-tier system
3. Assign appropriate modules based on old roles
"""
import sqlite3
import json
import os
import sys
# Add the app directory to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
def get_db_connections():
"""Get both internal SQLite and external database connections"""
connections = {}
# Internal SQLite database
internal_db_path = os.path.join(os.path.dirname(__file__), 'instance/users.db')
if os.path.exists(internal_db_path):
connections['internal'] = sqlite3.connect(internal_db_path)
print(f"Connected to internal SQLite database: {internal_db_path}")
# External database (try to connect using existing method)
try:
import mariadb
# Read external database configuration
config_file = os.path.join(os.path.dirname(__file__), '../external_database_settings')
if os.path.exists(config_file):
with open(config_file, 'r') as f:
lines = f.read().strip().split('\n')
if len(lines) >= 5:
host = lines[0].strip()
port = int(lines[1].strip())
database = lines[2].strip()
user = lines[3].strip()
password = lines[4].strip()
conn = mariadb.connect(
user=user,
password=password,
host=host,
port=port,
database=database
)
connections['external'] = conn
print(f"Connected to external MariaDB database: {host}:{port}/{database}")
except Exception as e:
print(f"Could not connect to external database: {e}")
return connections
def role_mapping():
"""Map old roles to new 4-tier system"""
return {
# Old role -> (new_role, modules)
'superadmin': ('superadmin', []), # All modules by default
'administrator': ('admin', []), # All modules by default
'admin': ('admin', []), # All modules by default
'quality': ('manager', ['quality']),
'warehouse': ('manager', ['warehouse']),
'warehouse_manager': ('manager', ['warehouse']),
'scan': ('worker', ['quality']), # Assume scan users are quality workers
'etichete': ('manager', ['labels']),
'quality_manager': ('manager', ['quality']),
'quality_worker': ('worker', ['quality']),
}
def migrate_database(conn, db_type):
"""Migrate a specific database"""
cursor = conn.cursor()
print(f"Migrating {db_type} database...")
# Check if users table exists
if db_type == 'internal':
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
else: # external/MariaDB
cursor.execute("SHOW TABLES LIKE 'users'")
if not cursor.fetchone():
print(f"No users table found in {db_type} database")
return
# Check if modules column already exists
try:
if db_type == 'internal':
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
else: # external/MariaDB
cursor.execute("DESCRIBE users")
columns = [row[0] for row in cursor.fetchall()]
if 'modules' not in columns:
print(f"Adding modules column to {db_type} database...")
if db_type == 'internal':
cursor.execute("ALTER TABLE users ADD COLUMN modules TEXT")
else: # external/MariaDB
cursor.execute("ALTER TABLE users ADD COLUMN modules TEXT")
else:
print(f"Modules column already exists in {db_type} database")
except Exception as e:
print(f"Error checking/adding modules column in {db_type}: {e}")
return
# Get current users
cursor.execute("SELECT id, username, role FROM users")
users = cursor.fetchall()
print(f"Found {len(users)} users in {db_type} database")
# Convert roles and assign modules
mapping = role_mapping()
updates = []
for user_id, username, old_role in users:
if old_role in mapping:
new_role, modules = mapping[old_role]
modules_json = json.dumps(modules) if modules else None
updates.append((new_role, modules_json, user_id, username))
print(f" {username}: {old_role} -> {new_role} with modules {modules}")
else:
print(f" {username}: Unknown role '{old_role}', keeping as-is")
# Apply updates
for new_role, modules_json, user_id, username in updates:
try:
cursor.execute("UPDATE users SET role = ?, modules = ? WHERE id = ?",
(new_role, modules_json, user_id))
print(f" Updated {username} successfully")
except Exception as e:
print(f" Error updating {username}: {e}")
conn.commit()
print(f"Migration completed for {db_type} database")
def main():
"""Main migration function"""
print("Starting migration to simplified 4-tier permission system...")
print("="*60)
connections = get_db_connections()
if not connections:
print("No database connections available. Please check your configuration.")
return
for db_type, conn in connections.items():
try:
migrate_database(conn, db_type)
print()
except Exception as e:
print(f"Error migrating {db_type} database: {e}")
finally:
conn.close()
print("Migration completed!")
print("\nNew role structure:")
print("- superadmin: Full system access")
print("- admin: Full app access (except role_permissions and download_extension)")
print("- manager: Module-based access (can have multiple modules)")
print("- worker: Limited module access (one module only)")
print("\nAvailable modules: quality, warehouse, labels")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Test script for the new simplified 4-tier permission system
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app'))
from permissions_simple import check_access, validate_user_modules, get_user_accessible_pages
def test_permission_system():
"""Test the new permission system with various scenarios"""
print("Testing Simplified 4-Tier Permission System")
print("=" * 50)
# Test cases: (role, modules, page, expected_result)
test_cases = [
# Superadmin tests
('superadmin', [], 'dashboard', True),
('superadmin', [], 'role_permissions', True),
('superadmin', [], 'quality', True),
('superadmin', [], 'warehouse', True),
# Admin tests
('admin', [], 'dashboard', True),
('admin', [], 'role_permissions', False), # Restricted for admin
('admin', [], 'download_extension', False), # Restricted for admin
('admin', [], 'quality', True),
('admin', [], 'warehouse', True),
# Manager tests
('manager', ['quality'], 'quality', True),
('manager', ['quality'], 'quality_reports', True),
('manager', ['quality'], 'warehouse', False), # No warehouse module
('manager', ['warehouse'], 'warehouse', True),
('manager', ['warehouse'], 'quality', False), # No quality module
('manager', ['quality', 'warehouse'], 'quality', True), # Multiple modules
('manager', ['quality', 'warehouse'], 'warehouse', True),
# Worker tests
('worker', ['quality'], 'quality', True),
('worker', ['quality'], 'quality_reports', False), # Workers can't access reports
('worker', ['quality'], 'warehouse', False), # No warehouse module
('worker', ['warehouse'], 'move_orders', True),
('worker', ['warehouse'], 'create_locations', False), # Workers can't create locations
# Invalid role test
('invalid_role', ['quality'], 'quality', False),
]
print("Testing access control:")
print("-" * 30)
passed = 0
failed = 0
for role, modules, page, expected in test_cases:
result = check_access(role, modules, page)
status = "PASS" if result == expected else "FAIL"
print(f"{status}: {role:12} {str(modules):20} {page:18} -> {result} (expected {expected})")
if result == expected:
passed += 1
else:
failed += 1
print(f"\nResults: {passed} passed, {failed} failed")
# Test module validation
print("\nTesting module validation:")
print("-" * 30)
validation_tests = [
('superadmin', ['quality'], True), # Superadmin can have any modules
('admin', ['warehouse'], True), # Admin can have any modules
('manager', ['quality'], True), # Manager can have one module
('manager', ['quality', 'warehouse'], True), # Manager can have multiple modules
('manager', [], False), # Manager must have at least one module
('worker', ['quality'], True), # Worker can have one module
('worker', ['quality', 'warehouse'], False), # Worker cannot have multiple modules
('worker', [], False), # Worker must have exactly one module
('invalid_role', ['quality'], False), # Invalid role
]
for role, modules, expected in validation_tests:
is_valid, error_msg = validate_user_modules(role, modules)
status = "PASS" if is_valid == expected else "FAIL"
print(f"{status}: {role:12} {str(modules):20} -> {is_valid} (expected {expected})")
if error_msg:
print(f" Error: {error_msg}")
# Test accessible pages for different users
print("\nTesting accessible pages:")
print("-" * 30)
user_tests = [
('superadmin', []),
('admin', []),
('manager', ['quality']),
('manager', ['warehouse']),
('worker', ['quality']),
('worker', ['warehouse']),
]
for role, modules in user_tests:
accessible_pages = get_user_accessible_pages(role, modules)
print(f"{role:12} {str(modules):20} -> {len(accessible_pages)} pages: {', '.join(accessible_pages[:5])}{'...' if len(accessible_pages) > 5 else ''}")
if __name__ == "__main__":
test_permission_system()

View File

@@ -0,0 +1,65 @@
package qz.communication;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.hid4java.HidDevice;
import org.hid4java.HidManager;
import org.hid4java.HidServices;
import javax.usb.util.UsbUtil;
import java.util.HashSet;
import java.util.List;
public class H4J_HidUtilities {
private static final HidServices service = HidManager.getHidServices();
public static List<HidDevice> getHidDevices() {
return service.getAttachedHidDevices();
}
public static JSONArray getHidDevicesJSON() throws JSONException {
List<HidDevice> devices = getHidDevices();
JSONArray devicesJSON = new JSONArray();
HashSet<String> unique = new HashSet<>();
for(HidDevice device : devices) {
JSONObject deviceJSON = new JSONObject();
deviceJSON.put("vendorId", UsbUtil.toHexString(device.getVendorId()))
.put("productId", UsbUtil.toHexString(device.getProductId()))
.put("usagePage", UsbUtil.toHexString((short)device.getUsagePage()))
.put("serial", device.getSerialNumber())
.put("manufacturer", device.getManufacturer())
.put("product", device.getProduct());
String uid = String.format("v%sp%su%ss%s", deviceJSON.optString("vendorId"), deviceJSON.optString("productId"), deviceJSON.optString("usagePage"), deviceJSON.optString("serial"));
if (!unique.contains(uid)) {
devicesJSON.put(deviceJSON);
unique.add(uid);
}
}
return devicesJSON;
}
public static HidDevice findDevice(DeviceOptions dOpts) {
if (dOpts.getVendorId() == null) {
throw new IllegalArgumentException("Vendor ID cannot be null");
}
if (dOpts.getProductId() == null) {
throw new IllegalArgumentException("Product ID cannot be null");
}
List<HidDevice> devices = getHidDevices();
for(HidDevice device : devices) {
if (device.isVidPidSerial(dOpts.getVendorId(), dOpts.getProductId(), dOpts.getSerial())
&& (dOpts.getUsagePage() == null || dOpts.getUsagePage() == device.getUsagePage())) {
return device;
}
}
return null;
}
}

View File

@@ -0,0 +1,154 @@
package qz.communication;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import purejavahidapi.HidDevice;
import purejavahidapi.HidDeviceInfo;
import purejavahidapi.InputReportListener;
import purejavahidapi.PureJavaHidApi;
import qz.utils.SystemUtilities;
import qz.ws.SocketConnection;
import javax.usb.util.UsbUtil;
import java.io.IOException;
import java.util.Vector;
public class PJHA_HidIO implements DeviceIO {
private static final Logger log = LogManager.getLogger(PJHA_HidIO.class);
private HidDeviceInfo deviceInfo;
private HidDevice device;
private static final int BUFFER_SIZE = 32;
private Vector<byte[]> dataBuffer;
private boolean streaming;
private DeviceOptions dOpts;
private SocketConnection websocket;
public PJHA_HidIO(DeviceOptions dOpts, SocketConnection websocket) throws DeviceException {
this(PJHA_HidUtilities.findDevice(dOpts), dOpts, websocket);
}
private PJHA_HidIO(HidDeviceInfo deviceInfo, DeviceOptions dOpts, SocketConnection websocket) throws DeviceException {
this.dOpts = dOpts;
this.websocket = websocket;
if (deviceInfo == null) {
throw new DeviceException("HID device could not be found");
}
this.deviceInfo = deviceInfo;
dataBuffer = new Vector<byte[]>() {
@Override
public synchronized boolean add(byte[] e) {
while(this.size() >= BUFFER_SIZE) {
this.remove(0);
}
return super.add(e);
}
};
}
public void open() throws DeviceException {
if (!isOpen()) {
try {
device = PureJavaHidApi.openDevice(deviceInfo);
device.setInputReportListener(new InputReportListener() {
@Override
public void onInputReport(HidDevice source, byte id, byte[] data, int len) {
byte[] dataCopy = new byte[len];
System.arraycopy(data, 0, dataCopy, 0, len);
dataBuffer.add(dataCopy);
}
});
}
catch(IOException ex) {
throw new DeviceException(ex);
}
}
}
public boolean isOpen() {
return device != null;
}
public void setStreaming(boolean active) {
streaming = active;
}
public boolean isStreaming() {
return streaming;
}
public String getVendorId() {
return UsbUtil.toHexString(deviceInfo.getVendorId());
}
public String getProductId() {
return UsbUtil.toHexString(deviceInfo.getProductId());
}
public byte[] readData(int responseSize, Byte unused) throws DeviceException {
byte[] response = new byte[responseSize];
if (dataBuffer.isEmpty()) {
return new byte[0]; //no data received yet
}
byte[] latestData = dataBuffer.remove(0);
if (SystemUtilities.isWindows()) {
//windows missing the leading byte
System.arraycopy(latestData, 0, response, 1, Math.min(responseSize - 1, latestData.length));
} else {
System.arraycopy(latestData, 0, response, 0, Math.min(responseSize - 1, latestData.length));
}
return response;
}
public void sendData(byte[] data, Byte reportId) throws DeviceException {
if (reportId == null) { reportId = (byte)0x00; }
int wrote = device.setOutputReport(reportId, data, data.length);
if (wrote == -1) {
throw new DeviceException("Failed to write to device");
}
}
public byte[] getFeatureReport(int responseSize, Byte unused) throws DeviceException {
byte[] response = new byte[responseSize];
int read = device.getFeatureReport(response, responseSize);
if (read == -1) {
throw new DeviceException("Failed to read from device");
}
return response;
}
public void sendFeatureReport(byte[] data, Byte reportId) throws DeviceException {
if (reportId == null) { reportId = (byte)0x00; }
int wrote = device.setFeatureReport(reportId, data, data.length);
if (wrote == -1) {
throw new DeviceException("Failed to write to device");
}
}
@Override
public void close() {
setStreaming(false);
// Remove orphaned reference
websocket.removeDevice(dOpts);
if (isOpen()) {
try {
device.setInputReportListener(null);
device.close();
}
catch(IllegalStateException e) {
log.warn("Device already closed");
}
}
device = null;
}
}

View File

@@ -0,0 +1,50 @@
package qz.communication;
import org.eclipse.jetty.websocket.api.Session;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import purejavahidapi.DeviceRemovalListener;
import purejavahidapi.HidDevice;
import qz.ws.PrintSocketClient;
import qz.ws.StreamEvent;
import javax.usb.util.UsbUtil;
public class PJHA_HidListener implements DeviceListener, DeviceRemovalListener {
private static final Logger log = LogManager.getLogger(PJHA_HidListener.class);
private Session session;
private HidDevice device;
public PJHA_HidListener(Session session) {
this.session = session;
}
public void setDevice(HidDevice device) {
this.device = device;
device.setDeviceRemovalListener(this);
}
private StreamEvent createStreamAction(HidDevice device, String action) {
return new StreamEvent(StreamEvent.Stream.HID, StreamEvent.Type.ACTION)
.withData("vendorId", UsbUtil.toHexString(device.getHidDeviceInfo().getVendorId()))
.withData("productId", UsbUtil.toHexString(device.getHidDeviceInfo().getProductId()))
.withData("actionType", action);
}
@Override
public void close() {
if (device != null) {
device.setDeviceRemovalListener(null);
}
}
@Override
public void onDeviceRemoval(HidDevice device) {
log.debug("Device detached: {}", device.getHidDeviceInfo().getProductString());
PrintSocketClient.sendStream(session, createStreamAction(device, "Device Detached"), this);
}
}

View File

@@ -0,0 +1,56 @@
package qz.communication;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import purejavahidapi.HidDeviceInfo;
import purejavahidapi.PureJavaHidApi;
import javax.usb.util.UsbUtil;
import java.util.List;
public class PJHA_HidUtilities {
public static JSONArray getHidDevicesJSON() throws JSONException {
List<HidDeviceInfo> devices = PureJavaHidApi.enumerateDevices();
JSONArray devicesJSON = new JSONArray();
for(HidDeviceInfo device : devices) {
JSONObject deviceJSON = new JSONObject();
deviceJSON.put("vendorId", UsbUtil.toHexString(device.getVendorId()))
.put("productId", UsbUtil.toHexString(device.getProductId()))
.put("usagePage", UsbUtil.toHexString(device.getUsagePage()))
.put("serial", device.getSerialNumberString())
.put("manufacturer", device.getManufacturerString())
.put("product", device.getProductString());
devicesJSON.put(deviceJSON);
}
return devicesJSON;
}
public static HidDeviceInfo findDevice(DeviceOptions dOpts) {
if (dOpts.getVendorId() == null) {
throw new IllegalArgumentException("Vendor ID cannot be null");
}
if (dOpts.getProductId() == null) {
throw new IllegalArgumentException("Product ID cannot be null");
}
List<HidDeviceInfo> devList = PureJavaHidApi.enumerateDevices();
for(HidDeviceInfo device : devList) {
if (device.getVendorId() == dOpts.getVendorId().shortValue() && device.getProductId() == dOpts.getProductId().shortValue()
&& (dOpts.getUsagePage() == null || dOpts.getUsagePage().shortValue() == device.getUsagePage())
&& (dOpts.getSerial() == null || dOpts.getSerial().equals(device.getSerialNumberString()))) {
return device;
}
}
return null;
}
}

View File

@@ -0,0 +1,297 @@
package qz.communication;
import jssc.*;
import org.apache.commons.codec.binary.StringUtils;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.ByteArrayBuilder;
import qz.utils.ByteUtilities;
import qz.utils.DeviceUtilities;
import qz.ws.SocketConnection;
import java.io.IOException;
/**
* @author Tres
*/
public class SerialIO implements DeviceListener {
private static final Logger log = LogManager.getLogger(SerialIO.class);
// Timeout to wait before giving up on reading the specified amount of bytes
private static final int TIMEOUT = 1200;
private String portName;
private SerialPort port;
private SerialOptions serialOpts;
private ByteArrayBuilder data = new ByteArrayBuilder();
private SocketConnection websocket;
/**
* Controller for serial communications
*
* @param portName Port name to open, such as "COM1" or "/dev/tty0/"
*/
public SerialIO(String portName, SocketConnection websocket) {
this.portName = portName;
this.websocket = websocket;
}
/**
* Open the specified port name.
*
* @param opts Parsed serial options
* @return Boolean indicating success.
* @throws SerialPortException If the port fails to open.
*/
public boolean open(SerialOptions opts) throws SerialPortException {
if (isOpen()) {
log.warn("Serial port [{}] is already open", portName);
return false;
}
port = new SerialPort(portName);
port.openPort();
serialOpts = new SerialOptions();
setOptions(opts);
return port.isOpened();
}
public void applyPortListener(SerialPortEventListener listener) throws SerialPortException {
port.addEventListener(listener);
}
/**
* @return Boolean indicating if port is currently open.
*/
public boolean isOpen() {
return port != null && port.isOpened();
}
public String processSerialEvent(SerialPortEvent event) {
SerialOptions.ResponseFormat format = serialOpts.getResponseFormat();
try {
// Receive data
if (event.isRXCHAR()) {
data.append(port.readBytes(event.getEventValue(), TIMEOUT));
String response = null;
if (format.isBoundNewline()) {
//process as line delimited
// check for CR AND NL
Integer endIdx = ByteUtilities.firstMatchingIndex(data.getByteArray(), new byte[] {'\r', '\n'});
int delimSize = 2;
// check for CR OR NL
if(endIdx == null) {
endIdx = min(
ByteUtilities.firstMatchingIndex(data.getByteArray(), new byte[] {'\r'}),
ByteUtilities.firstMatchingIndex(data.getByteArray(), new byte[] {'\n'}));
delimSize = 1;
}
if (endIdx != null) {
log.trace("Reading newline-delimited response");
byte[] output = new byte[endIdx];
System.arraycopy(data.getByteArray(), 0, output, 0, endIdx);
String buffer = new String(output, format.getEncoding());
if (!buffer.isEmpty()) {
//send non-empty string
response = buffer;
}
data.clearRange(0, endIdx + delimSize);
}
} else if (format.getBoundStart() != null && format.getBoundStart().length > 0) {
//process as formatted response
Integer startIdx = ByteUtilities.firstMatchingIndex(data.getByteArray(), format.getBoundStart());
if (startIdx != null) {
int startOffset = startIdx + format.getBoundStart().length;
int copyLength = 0;
int endIdx = 0;
if (format.getBoundEnd() != null && format.getBoundEnd().length > 0) {
//process as bounded response
Integer boundEnd = ByteUtilities.firstMatchingIndex(data.getByteArray(), format.getBoundEnd(), startIdx);
if (boundEnd != null) {
log.trace("Reading bounded response");
copyLength = boundEnd - startOffset;
endIdx = boundEnd + 1;
if (format.isIncludeStart()) {
//also include the ending bytes
copyLength += format.getBoundEnd().length;
}
}
} else if (format.getFixedWidth() > 0) {
//process as fixed length prefixed response
log.trace("Reading fixed length prefixed response");
copyLength = format.getFixedWidth();
endIdx = startOffset + format.getFixedWidth();
} else if (format.getLength() != null) {
//process as dynamic formatted response
SerialOptions.ByteParam lengthParam = format.getLength();
if (data.getLength() > startOffset + lengthParam.getIndex() + lengthParam.getLength()) { //ensure there's length bytes to read
log.trace("Reading dynamic formatted response");
int expectedLength = ByteUtilities.parseBytes(data.getByteArray(), startOffset + lengthParam.getIndex(), lengthParam.getLength(), lengthParam.getEndian());
log.trace("Found length byte, expecting {} bytes", expectedLength);
startOffset += lengthParam.getIndex() + lengthParam.getLength(); // don't include the length byte(s) in the response
copyLength = expectedLength;
endIdx = startOffset + copyLength;
if (format.getCrc() != null) {
SerialOptions.ByteParam crcParam = format.getCrc();
log.trace("Expecting {} crc bytes", crcParam.getLength());
int expand = crcParam.getIndex() + crcParam.getLength();
//include crc in copy
copyLength += expand;
endIdx += expand;
}
}
} else {
//process as header formatted raw response - high risk of lost data, likely unintended settings
log.warn("Reading header formatted raw response, are you missing an rx option?");
copyLength = data.getLength() - startOffset;
endIdx = data.getLength();
}
if (copyLength > 0 && data.getLength() >= endIdx) {
log.debug("Response format readable, starting copy");
if (format.isIncludeStart()) {
//increase length to account for header bytes and bump offset back to include in copy
copyLength += (startOffset - startIdx);
startOffset = startIdx;
}
byte[] responseData = new byte[copyLength];
System.arraycopy(data.getByteArray(), startOffset, responseData, 0, copyLength);
response = new String(responseData, format.getEncoding());
data.clearRange(startIdx, endIdx);
}
}
} else if (format.getFixedWidth() > 0) {
if (data.getLength() >= format.getFixedWidth()) {
//process as fixed width response
log.trace("Reading fixed length response");
byte[] output = new byte[format.getFixedWidth()];
System.arraycopy(data.getByteArray(), 0, output, 0, format.getFixedWidth());
response = StringUtils.newStringUtf8(output);
data.clearRange(0, format.getFixedWidth());
}
} else {
//no processing, return raw
log.trace("Reading raw response");
response = new String(data.getByteArray(), format.getEncoding());
data.clear();
}
return response;
}
}
catch(SerialPortException e) {
log.error("Exception occurred while reading data from port.", e);
}
catch(SerialPortTimeoutException e) {
log.error("Timeout occurred waiting for port to respond.", e);
}
return null;
}
/**
* Sets and caches the properties as to not set them every data call
*
* @throws SerialPortException If the properties fail to set
*/
private void setOptions(SerialOptions opts) throws SerialPortException {
if (opts == null) { return; }
SerialOptions.PortSettings ps = opts.getPortSettings();
if (ps != null && !ps.equals(serialOpts.getPortSettings())) {
log.debug("Applying new port settings");
port.setParams(ps.getBaudRate(), ps.getDataBits(), ps.getStopBits(), ps.getParity());
port.setFlowControlMode(ps.getFlowControl());
serialOpts.setPortSettings(ps);
}
SerialOptions.ResponseFormat rf = opts.getResponseFormat();
if (rf != null) {
log.debug("Applying new response formatting");
serialOpts.setResponseFormat(rf);
}
}
/**
* Applies the port parameters and writes the buffered data to the serial port.
*/
public void sendData(JSONObject params, SerialOptions opts) throws JSONException, IOException, SerialPortException {
if (opts != null) {
setOptions(opts);
}
log.debug("Sending data over [{}]", portName);
port.writeBytes(DeviceUtilities.getDataBytes(params, serialOpts.getPortSettings().getEncoding()));
}
/**
* Closes the serial port, if open.
*
* @throws SerialPortException If the port fails to close.
*/
@Override
public void close() {
// Remove orphaned reference
websocket.removeSerialPort(portName);
if (!isOpen()) {
log.warn("Serial port [{}] is not open.", portName);
}
try {
boolean closed = port.closePort();
if (closed) {
log.info("Serial port [{}] closed successfully.", portName);
} else {
// Handle ambiguity in JSSCs API
throw new SerialPortException(portName, "closePort", "Port not closed");
}
} catch(SerialPortException e) {
log.warn("Serial port [{}] was not closed properly.", portName);
}
port = null;
portName = null;
}
private Integer min(Integer a, Integer b) {
if (a == null) { return b; }
if (b == null) { return a; }
return Math.min(a, b);
}
}

View File

@@ -0,0 +1,350 @@
package qz.communication;
import jssc.SerialPort;
import org.apache.commons.lang3.ArrayUtils;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.ByteUtilities;
import qz.utils.DeviceUtilities;
import qz.utils.LoggerUtilities;
import qz.utils.SerialUtilities;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Locale;
public class SerialOptions {
private static final Logger log = LogManager.getLogger(SerialOptions.class);
private static final String DEFAULT_BEGIN = "0x0002";
private static final String DEFAULT_END = "0x000D";
private PortSettings portSettings = null;
private ResponseFormat responseFormat = null;
/**
* Creates an empty/default options object
*/
public SerialOptions() {
portSettings = new PortSettings();
responseFormat = new ResponseFormat();
}
/**
* Parses the provided JSON object into relevant SerialPort constants
*/
public SerialOptions(JSONObject serialOpts, boolean isOpening) {
if (serialOpts == null) { return; }
//only apply port settings if opening or explicitly set in a send data call
if (isOpening || serialOpts.has("baudRate") || serialOpts.has("dataBits") || serialOpts.has("stopBits") || serialOpts.has("parity") || serialOpts.has("flowControl")) {
portSettings = new PortSettings();
if (!serialOpts.isNull("baudRate")) {
try { portSettings.baudRate = SerialUtilities.parseBaudRate(serialOpts.getString("baudRate")); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "baudRate", serialOpts.opt("baudRate")); }
}
if (!serialOpts.isNull("dataBits")) {
try { portSettings.dataBits = SerialUtilities.parseDataBits(serialOpts.getString("dataBits")); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "dataBits", serialOpts.opt("dataBits")); }
}
if (!serialOpts.isNull("stopBits")) {
try { portSettings.stopBits = SerialUtilities.parseStopBits(serialOpts.getString("stopBits")); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "stopBits", serialOpts.opt("stopBits")); }
}
if (!serialOpts.isNull("parity")) {
try { portSettings.parity = SerialUtilities.parseParity(serialOpts.getString("parity")); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "parity", serialOpts.opt("parity")); }
}
if (!serialOpts.isNull("flowControl")) {
try { portSettings.flowControl = SerialUtilities.parseFlowControl(serialOpts.getString("flowControl")); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "flowControl", serialOpts.opt("flowControl")); }
}
if (!serialOpts.isNull("encoding") && !serialOpts.optString("encoding").isEmpty()) {
try { portSettings.encoding = Charset.forName(serialOpts.getString("encoding")); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "encoding", serialOpts.opt("encoding")); }
}
}
if (!serialOpts.isNull("rx")) {
responseFormat = new ResponseFormat();
//Make the response encoding default to the port encoding. If this is removed it will default to UTF-8
responseFormat.encoding = portSettings.encoding;
JSONObject respOpts = serialOpts.optJSONObject("rx");
if (respOpts != null) {
if (!respOpts.isNull("start")) {
try {
JSONArray startBits = respOpts.getJSONArray("start");
ArrayList<Byte> bytes = new ArrayList<>();
for(int i = 0; i < startBits.length(); i++) {
byte[] charByte = DeviceUtilities.characterBytes(startBits.getString(i), responseFormat.encoding);
for(byte b : charByte) { bytes.add(b); }
}
responseFormat.boundStart = ArrayUtils.toPrimitive(bytes.toArray(new Byte[0]));
}
catch(JSONException e) {
try { responseFormat.boundStart = DeviceUtilities.characterBytes(respOpts.getString("start"), responseFormat.encoding); }
catch(JSONException e2) { LoggerUtilities.optionWarn(log, "string", "start", respOpts.opt("start")); }
}
}
if (!respOpts.isNull("includeHeader")) {
try { responseFormat.includeStart = respOpts.getBoolean("includeHeader"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "includeHeader", respOpts.opt("includeHeader")); }
}
if (!respOpts.isNull("end")) {
try { responseFormat.boundEnd = DeviceUtilities.characterBytes(respOpts.getString("end"), responseFormat.encoding); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "string", "end", respOpts.opt("end")); }
if (responseFormat.boundStart == null || responseFormat.boundStart.length == 0) {
log.warn("End bound set without start bound defined");
}
}
if (!respOpts.isNull("untilNewline")) {
try { responseFormat.boundNewline = respOpts.getBoolean("untilNewline"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "untilNewline", respOpts.opt("untilNewline")); }
}
if (!respOpts.isNull("width")) {
try { responseFormat.fixedWidth = respOpts.getInt("width"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "width", respOpts.opt("width")); }
}
if (!respOpts.isNull("lengthBytes")) {
try {
JSONObject lengthOpts = respOpts.optJSONObject("lengthBytes");
responseFormat.length = new ByteParam();
if (lengthOpts != null) {
if (!lengthOpts.isNull("index")) {
try { responseFormat.length.index = lengthOpts.getInt("index"); }
catch(JSONException se) { LoggerUtilities.optionWarn(log, "integer", "lengthBytes.index", lengthOpts.opt("index")); }
}
if (!lengthOpts.isNull("length")) {
try { responseFormat.length.length = lengthOpts.getInt("length"); }
catch(JSONException se) { LoggerUtilities.optionWarn(log, "integer", "lengthBytes.length", lengthOpts.opt("length")); }
}
if (!lengthOpts.isNull("endian")) {
try { responseFormat.length.endian = ByteUtilities.Endian.valueOf(lengthOpts.getString("endian").toUpperCase(Locale.ENGLISH)); }
catch(JSONException se) { LoggerUtilities.optionWarn(log, "string", "lengthBytes.endian", lengthOpts.opt("endian")); }
}
} else {
responseFormat.length.index = respOpts.getInt("lengthBytes");
}
}
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "lengthBytes", respOpts.opt("lengthBytes")); }
if (responseFormat.boundStart == null || responseFormat.boundStart.length == 0) {
log.warn("Length byte(s) defined without start bound defined");
}
}
if (!respOpts.isNull("crcBytes")) {
try {
JSONObject crcOpts = respOpts.optJSONObject("crcBytes");
responseFormat.crc = new ByteParam();
if (crcOpts != null) {
if (!crcOpts.isNull("index")) {
try { responseFormat.crc.index = crcOpts.getInt("index"); }
catch(JSONException se) { LoggerUtilities.optionWarn(log, "integer", "crcBytes.index", crcOpts.opt("index")); }
}
if (!crcOpts.isNull("length")) {
try { responseFormat.crc.length = crcOpts.getInt("length"); }
catch(JSONException se) { LoggerUtilities.optionWarn(log, "integer", "crcBytes.length", crcOpts.opt("length")); }
}
} else {
responseFormat.crc.length = respOpts.getInt("crcBytes");
}
}
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "crcBytes", respOpts.opt("crcBytes")); }
if (responseFormat.boundStart == null || responseFormat.boundStart.length == 0) {
log.warn("CRC byte(s) defined without start bound defined");
}
}
if (!respOpts.isNull("encoding") && !respOpts.optString("encoding").isEmpty()) {
try { responseFormat.encoding = Charset.forName(respOpts.getString("encoding")); }
catch(JSONException | IllegalArgumentException e) { LoggerUtilities.optionWarn(log, "charset", "encoding", respOpts.opt("encoding")); }
}
} else {
LoggerUtilities.optionWarn(log, "JSONObject", "rx", serialOpts.opt("rx"));
}
} else if (isOpening) {
// legacy support - only applies on port open
responseFormat = new ResponseFormat();
// legacy start only supports string, not an array
if (!serialOpts.isNull("start")) {
responseFormat.boundStart = DeviceUtilities.characterBytes(serialOpts.optString("start", DEFAULT_BEGIN), responseFormat.encoding);
} else {
responseFormat.boundStart = DeviceUtilities.characterBytes(DEFAULT_BEGIN, responseFormat.encoding);
}
if (!serialOpts.isNull("end")) {
responseFormat.boundEnd = DeviceUtilities.characterBytes(serialOpts.optString("end", DEFAULT_END), responseFormat.encoding);
} else {
responseFormat.boundEnd = DeviceUtilities.characterBytes(DEFAULT_END, responseFormat.encoding);
}
if (!serialOpts.isNull("width")) {
try {
responseFormat.fixedWidth = serialOpts.getInt("width");
if (responseFormat.boundEnd.length > 0) {
log.warn("Combining 'width' property with 'end' property has undefined behavior and should not be used");
}
}
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "width", serialOpts.opt("width")); }
}
}
}
public PortSettings getPortSettings() {
return portSettings;
}
public ResponseFormat getResponseFormat() {
return responseFormat;
}
public void setPortSettings(PortSettings portSettings) {
this.portSettings = portSettings;
}
public void setResponseFormat(ResponseFormat responseFormat) {
this.responseFormat = responseFormat;
}
public class PortSettings {
private Charset encoding = Charset.forName("UTF-8");
private int baudRate = SerialPort.BAUDRATE_9600;
private int dataBits = SerialPort.DATABITS_8;
private int stopBits = SerialPort.STOPBITS_1;
private int parity = SerialPort.PARITY_NONE;
private int flowControl = SerialPort.FLOWCONTROL_NONE;
public Charset getEncoding() {
return encoding;
}
public int getBaudRate() {
return baudRate;
}
public int getDataBits() {
return dataBits;
}
public int getStopBits() {
return stopBits;
}
public int getParity() {
return parity;
}
public int getFlowControl() {
return flowControl;
}
@Override
public boolean equals(Object o) {
if (o instanceof PortSettings) {
PortSettings that = (PortSettings)o;
return getEncoding().equals(that.getEncoding()) &&
getBaudRate() == that.getBaudRate() &&
getDataBits() == that.getDataBits() &&
getStopBits() == that.getStopBits() &&
getParity() == that.getParity() &&
getFlowControl() == that.getFlowControl();
} else {
return false;
}
}
}
public class ResponseFormat {
private Charset encoding = Charset.forName("UTF-8"); //Response charset
private byte[] boundStart; //Character(s) denoting start of new response
private byte[] boundEnd; //Character denoting end of a response
private boolean boundNewline; //If the response should be split on \r?\n
private int fixedWidth; //Fixed length response bounds
private ByteParam length; //Info about the data length byte(s)
private ByteParam crc; //Info about the data crc byte(s)
private boolean includeStart; //If the response headers should be sent as well
public Charset getEncoding() {
return encoding;
}
public byte[] getBoundStart() {
return boundStart;
}
public byte[] getBoundEnd() {
return boundEnd;
}
public int getFixedWidth() {
return fixedWidth;
}
public ByteParam getLength() {
return length;
}
public ByteParam getCrc() {
return crc;
}
public boolean isIncludeStart() {
return includeStart;
}
public boolean isBoundNewline() {
return boundNewline;
}
}
public class ByteParam {
private int index = 0;
private int length = 1;
private ByteUtilities.Endian endian = ByteUtilities.Endian.BIG;
public int getIndex() {
return index;
}
public int getLength() {
return length;
}
public ByteUtilities.Endian getEndian() {
return endian;
}
}
}

View File

@@ -0,0 +1,100 @@
package qz.communication;
import org.apache.commons.lang3.ArrayUtils;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.DeviceUtilities;
import qz.utils.NetworkUtilities;
import qz.ws.SocketConnection;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.nio.charset.Charset;
import java.util.ArrayList;
public class SocketIO implements DeviceListener {
private static final Logger log = LogManager.getLogger(SocketIO.class);
private String host;
private int port;
private Charset encoding;
private Socket socket;
private DataOutputStream dataOut;
private DataInputStream dataIn;
private SocketConnection websocket;
public SocketIO(String host, int port, Charset encoding, SocketConnection websocket) {
this.host = host;
this.port = port;
this.encoding = encoding;
this.websocket = websocket;
}
public boolean open() throws IOException {
socket = new Socket(host, port);
socket.setSoTimeout(NetworkUtilities.SOCKET_TIMEOUT);
dataOut = new DataOutputStream(socket.getOutputStream());
dataIn = new DataInputStream(socket.getInputStream());
return socket.isConnected();
}
public boolean isOpen() {
return socket.isConnected();
}
public void sendData(JSONObject params) throws JSONException, IOException {
log.debug("Sending data over [{}:{}]", host, port);
dataOut.write(DeviceUtilities.getDataBytes(params, encoding));
dataOut.flush();
}
public String processSocketResponse() throws IOException {
byte[] response = new byte[1024];
ArrayList<Byte> fullResponse = new ArrayList<>();
do {
int size = dataIn.read(response);
for(int i = 0; i < size; i++) {
fullResponse.add(response[i]);
}
}
while(dataIn.available() > 0);
if(fullResponse.size() > 0) {
return new String(ArrayUtils.toPrimitive(fullResponse.toArray(new Byte[0])), encoding);
}
return null;
}
@Override
public void close() {
// Remove orphaned reference
websocket.removeNetworkSocket(String.format("%s:%s", host, port));
try {
dataOut.close();
} catch(IOException e) {
log.warn("Could not close socket output stream", e);
}
try {
socket.close();
} catch(IOException e) {
log.warn("Could not close socket", e);
}
}
public String getHost() {
return host;
}
public int getPort() {
return port;
}
}

View File

@@ -0,0 +1,153 @@
package qz.communication;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.UsbUtilities;
import qz.ws.SocketConnection;
import javax.usb.*;
import javax.usb.util.UsbUtil;
public class UsbIO implements DeviceIO {
private static final Logger log = LogManager.getLogger(UsbIO.class);
private UsbDevice device;
private UsbInterface iface;
private boolean streaming;
private DeviceOptions dOpts;
private SocketConnection websocket;
public UsbIO(DeviceOptions dOpts, SocketConnection websocket) throws DeviceException {
this.dOpts = dOpts;
this.websocket = websocket;
UsbDevice device = UsbUtilities.findDevice(dOpts.getVendorId().shortValue(), dOpts.getProductId().shortValue());
if (device == null) {
throw new DeviceException("USB device could not be found");
}
if (dOpts.getInterfaceId() == null) {
throw new IllegalArgumentException("Device interface cannot be null");
}
this.iface = device.getActiveUsbConfiguration().getUsbInterface(dOpts.getInterfaceId());
if (iface == null) {
throw new DeviceException(String.format("Could not find USB interface matching [ vendorId: '%s', productId: '%s', interface: '%s' ]",
"0x" + UsbUtil.toHexString(dOpts.getVendorId()),
"0x" + UsbUtil.toHexString(dOpts.getProductId()),
"0x" + UsbUtil.toHexString(dOpts.getInterfaceId())));
}
this.device = device;
}
public void open() throws DeviceException {
try {
iface.claim(new UsbInterfacePolicy() {
@Override
public boolean forceClaim(UsbInterface usbInterface) {
// Releases kernel driver for systems that auto-claim usb devices
return true;
}
});
}
catch(UsbException e) {
throw new DeviceException(e);
}
}
public boolean isOpen() {
return iface.isClaimed();
}
public void setStreaming(boolean active) {
streaming = active;
}
public boolean isStreaming() {
return streaming;
}
public String getVendorId() {
return UsbUtil.toHexString(device.getUsbDeviceDescriptor().idVendor());
}
public String getProductId() {
return UsbUtil.toHexString(device.getUsbDeviceDescriptor().idProduct());
}
public String getInterface() {
return UsbUtil.toHexString(iface.getUsbInterfaceDescriptor().iInterface());
}
public byte[] readData(int responseSize, Byte endpoint) throws DeviceException {
try {
byte[] response = new byte[responseSize];
exchangeData(endpoint, response);
return response;
}
catch(UsbException e) {
throw new DeviceException(e);
}
}
public void sendData(byte[] data, Byte endpoint) throws DeviceException {
try {
exchangeData(endpoint, data);
}
catch(UsbException e) {
throw new DeviceException(e);
}
}
public byte[] getFeatureReport(int responseSize, Byte reportId) throws DeviceException {
throw new DeviceException("USB feature reports are not supported");
}
public void sendFeatureReport(byte[] data, Byte reportId) throws DeviceException {
throw new DeviceException("USB feature reports are not supported");
}
/**
* Data will be sent to or received from the open usb device, depending on the {@code endpoint} used.
*
* @param endpoint Endpoint on the usb device interface to pass data across
* @param data Byte array of data to send, or to be written from a receive
*/
private synchronized void exchangeData(Byte endpoint, byte[] data) throws UsbException, DeviceException {
if (endpoint == null) {
throw new IllegalArgumentException("Interface endpoint cannot be null");
}
UsbEndpoint usbEndpoint = iface.getUsbEndpoint(endpoint);
if(usbEndpoint == null) {
throw new DeviceException(String.format("Could not find USB endpoint matching [ endpoint: '%s' ]",
"0x" + UsbUtil.toHexString(endpoint)));
}
UsbPipe pipe = usbEndpoint.getUsbPipe();
if (!pipe.isOpen()) { pipe.open(); }
try {
pipe.syncSubmit(data);
}
finally {
if(pipe != null) {
pipe.close();
}
}
}
@Override
public void close() {
setStreaming(false);
// Remove orphaned reference
websocket.removeDevice(dOpts);
if (iface.isClaimed()) {
try {
iface.release();
}
catch(UsbException e) {
log.error("Unable to close USB device", e);
}
}
}
}

View File

@@ -0,0 +1,28 @@
package qz.communication;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.platform.win32.WinNT;
import com.sun.jna.platform.win32.Winspool;
import com.sun.jna.win32.W32APIOptions;
/**
* TODO: Remove when JNA 5.14.0+ is bundled
*/
@SuppressWarnings("unused")
public interface WinspoolEx extends Winspool {
WinspoolEx INSTANCE = Native.load("Winspool.drv", WinspoolEx.class, W32APIOptions.DEFAULT_OPTIONS);
int JOB_CONTROL_NONE = 0x00000000; // Perform no additional action.
int JOB_CONTROL_PAUSE = 0x00000001; // Pause the print job.
int JOB_CONTROL_RESUME = 0x00000002; // Resume a paused print job.
int JOB_CONTROL_CANCEL = 0x00000003; // Delete a print job.
int JOB_CONTROL_RESTART = 0x00000004; // Restart a print job.
int JOB_CONTROL_DELETE = 0x00000005; // Delete a print job.
int JOB_CONTROL_SENT_TO_PRINTER = 0x00000006; // Used by port monitors to signal that a print job has been sent to the printer. This value SHOULD NOT be used remotely.
int JOB_CONTROL_LAST_PAGE_EJECTED = 0x00000007; // Used by language monitors to signal that the last page of a print job has been ejected from the printer. This value SHOULD NOT be used remotely.
int JOB_CONTROL_RETAIN = 0x00000008; // Keep the print job in the print queue after it prints.
int JOB_CONTROL_RELEASE = 0x00000009; // Release the print job, undoing the effect of a JOB_CONTROL_RETAIN action.
boolean SetJob(WinNT.HANDLE hPrinter, int JobId, int Level, Pointer pJob, int Command);
}

View File

@@ -0,0 +1,11 @@
package qz.exception;
public class InvalidRawImageException extends Exception {
public InvalidRawImageException(String msg) {
super(msg);
}
public InvalidRawImageException(String msg, Throwable cause) {
super(msg, cause);
}
}

View File

@@ -0,0 +1,3 @@
package qz.exception;
public class MissingArgException extends Exception {}

View File

@@ -0,0 +1,10 @@
package qz.exception;
public class NullCommandException extends javax.print.PrintException {
public NullCommandException() {
super();
}
public NullCommandException(String msg) {
super(msg);
}
}

View File

@@ -0,0 +1,7 @@
package qz.exception;
public class NullPrintServiceException extends javax.print.PrintException {
public NullPrintServiceException(String msg) {
super(msg);
}
}

View File

@@ -0,0 +1,413 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer;
import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.auth.Certificate;
import qz.build.provision.params.Phase;
import qz.installer.certificate.*;
import qz.installer.certificate.firefox.FirefoxCertificateInstaller;
import qz.installer.provision.ProvisionInstaller;
import qz.utils.FileUtilities;
import qz.utils.SystemUtilities;
import qz.ws.WebsocketPorts;
import java.io.*;
import java.nio.file.*;
import java.security.cert.X509Certificate;
import java.util.*;
import static qz.common.Constants.*;
import static qz.installer.certificate.KeyPairWrapper.Type.CA;
import static qz.utils.FileUtilities.*;
/**
* Cross-platform wrapper for install steps
* - Used by CommandParser via command line
* - Used by PrintSocketServer at startup to ensure SSL is functioning
*/
public abstract class Installer {
protected static final Logger log = LogManager.getLogger(Installer.class);
// Silence prompts within our control
public static boolean IS_SILENT = "1".equals(System.getenv(DATA_DIR + "_silent"));
public static String JRE_LOCATION = SystemUtilities.isMac() ? "Contents/PlugIns/Java.runtime/Contents/Home" : "runtime";
WebsocketPorts websocketPorts;
public enum PrivilegeLevel {
USER,
SYSTEM
}
public abstract Installer removeLegacyStartup();
public abstract Installer addAppLauncher();
public abstract Installer addStartupEntry();
public abstract Installer addSystemSettings();
public abstract Installer removeSystemSettings();
public abstract void spawn(List<String> args) throws Exception;
public abstract void setDestination(String destination);
public abstract String getDestination();
private static Installer instance;
public static Installer getInstance() {
if(instance == null) {
switch(SystemUtilities.getOs()) {
case WINDOWS:
instance = new WindowsInstaller();
break;
case MAC:
instance = new MacInstaller();
break;
default:
instance = new LinuxInstaller();
}
}
return instance;
}
public static void install(String destination, boolean silent) throws Exception {
IS_SILENT |= silent; // preserve environmental variable if possible
getInstance();
if (destination != null) {
instance.setDestination(destination);
}
install();
}
public static boolean preinstall() {
getInstance();
log.info("Fixing runtime permissions...");
instance.setJrePermissions(SystemUtilities.getAppPath().toString());
log.info("Stopping running instances...");
return TaskKiller.killAll();
}
public static void install() throws Exception {
getInstance();
log.info("Installing to {}", instance.getDestination());
instance.removeLibs()
.removeProvisioning()
.deployApp()
.removeLegacyStartup()
.removeLegacyFiles()
.addSharedDirectory()
.addAppLauncher()
.addStartupEntry()
.invokeProvisioning(Phase.INSTALL)
.addSystemSettings();
}
public static void uninstall() {
log.info("Stopping running instances...");
TaskKiller.killAll();
getInstance();
log.info("Uninstalling from {}", instance.getDestination());
instance.removeSharedDirectory()
.removeSystemSettings()
.removeCerts()
.invokeProvisioning(Phase.UNINSTALL);
}
public Installer deployApp() throws IOException {
Path src = SystemUtilities.getAppPath();
Path dest = Paths.get(getDestination());
if(!Files.exists(dest)) {
Files.createDirectories(dest);
}
// Delete the JDK blindly
FileUtils.deleteDirectory(dest.resolve(JRE_LOCATION).toFile());
// Note: preserveFileDate=false per https://github.com/qzind/tray/issues/1011
FileUtils.copyDirectory(src.toFile(), dest.toFile(), false);
FileUtilities.setPermissionsRecursively(dest, false);
// Fix permissions for provisioned files
FileUtilities.setExecutableRecursively(SystemUtilities.isMac() ?
dest.resolve("Contents/Resources").resolve(PROVISION_DIR) :
dest.resolve(PROVISION_DIR), false);
if(!SystemUtilities.isWindows()) {
setExecutable(SystemUtilities.isMac() ? "Contents/Resources/uninstall" : "uninstall");
setExecutable(SystemUtilities.isMac() ? "Contents/MacOS/" + ABOUT_TITLE : PROPS_FILE);
return setJrePermissions(getDestination());
}
return this;
}
private Installer setJrePermissions(String dest) {
File jreLocation = new File(dest, JRE_LOCATION);
File jreBin = new File(jreLocation, "bin");
File jreLib = new File(jreLocation, "lib");
// Set jre/bin/java and friends executable
File[] files = jreBin.listFiles(pathname -> !pathname.isDirectory());
if(files != null) {
for(File file : files) {
file.setExecutable(true, false);
}
}
// Set jspawnhelper executable
new File(jreLib, "jspawnhelper" + (SystemUtilities.isWindows() ? ".exe" : "")).setExecutable(true, false);
return this;
}
private void setExecutable(String relativePath) {
new File(getDestination(), relativePath).setExecutable(true, false);
}
/**
* Explicitly purge libs to notify system cache per https://github.com/qzind/tray/issues/662
*/
public Installer removeLibs() {
String[] dirs = { "libs" };
for (String dir : dirs) {
try {
FileUtils.deleteDirectory(new File(instance.getDestination() + File.separator + dir));
} catch(IOException ignore) {}
}
return this;
}
public Installer cleanupLegacyLogs(int rolloverCount) {
// Convert old < 2.2.3 log file format
Path logLocation = USER_DIR;
int oldIndex = 0;
int newIndex = 0;
File oldFile;
do {
// Old: debug.log.1
oldFile = logLocation.resolve("debug.log." + ++oldIndex).toFile();
if(oldFile.exists()) {
// New: debug.1.log
File newFile;
do {
newFile = logLocation.resolve("debug." + ++newIndex + ".log").toFile();
} while(newFile.exists());
oldFile.renameTo(newFile);
log.info("Migrated log file {} to new location {}", oldFile, newFile);
}
} while(oldFile.exists() || oldIndex <= rolloverCount);
return this;
}
public Installer removeLegacyFiles() {
ArrayList<String> dirs = new ArrayList<>();
ArrayList<String> files = new ArrayList<>();
HashMap<String, String> move = new HashMap<>();
// QZ Tray 2.0 files
dirs.add("demo/js/3rdparty");
dirs.add("utils");
dirs.add("auth");
files.add("demo/js/qz-websocket.js");
files.add("windows-icon.ico");
// QZ Tray 2.2.3-SNAPSHOT accidentally wrote certs in the wrong place
dirs.add("ssl");
// QZ Tray 2.1 files
if(SystemUtilities.isMac()) {
// Moved to macOS Application Bundle standard https://developer.apple.com/go/?id=bundle-structure
dirs.add("demo");
dirs.add("libs");
files.add(PROPS_FILE + ".jar");
files.add("LICENSE.txt");
files.add("uninstall");
move.put(PROPS_FILE + ".properties", "Contents/Resources/" + PROPS_FILE + ".properties");
}
dirs.forEach(dir -> {
try {
FileUtils.deleteDirectory(new File(instance.getDestination() + File.separator + dir));
} catch(IOException ignore) {}
});
files.forEach(file -> {
new File(instance.getDestination() + File.separator + file).delete();
});
move.forEach((src, dest) -> {
try {
FileUtils.moveFile(new File(instance.getDestination() + File.separator + src),
new File(instance.getDestination() + File.separator + dest));
} catch(IOException ignore) {}
});
return this;
}
public Installer addSharedDirectory() {
try {
Files.createDirectories(SHARED_DIR);
FileUtilities.setPermissionsRecursively(SHARED_DIR, true);
Path ssl = Paths.get(SHARED_DIR.toString(), "ssl");
Files.createDirectories(ssl);
FileUtilities.setPermissionsRecursively(ssl, true);
log.info("Created shared directory: {}", SHARED_DIR);
} catch(IOException e) {
log.warn("Could not create shared directory: {}", SHARED_DIR);
}
return this;
}
public Installer removeSharedDirectory() {
try {
FileUtils.deleteDirectory(SHARED_DIR.toFile());
log.info("Deleted shared directory: {}", SHARED_DIR);
} catch(IOException e) {
log.warn("Could not delete shared directory: {}", SHARED_DIR);
}
return this;
}
/**
* Checks, and if needed generates an SSL for the system
*/
public CertificateManager certGen(boolean forceNew, String... hostNames) throws Exception {
CertificateManager certificateManager = new CertificateManager(forceNew, hostNames);
boolean needsInstall = certificateManager.needsInstall();
try {
// Check that the CA cert is installed
X509Certificate caCert = certificateManager.getKeyPair(CA).getCert();
NativeCertificateInstaller installer = NativeCertificateInstaller.getInstance();
if (forceNew || needsInstall) {
// Remove installed certs per request (usually the desktop installer, or failure to write properties)
// Skip if running from IDE, this may accidentally remove sandboxed certs
if(SystemUtilities.isJar()) {
List<String> matchingCerts = installer.find();
installer.remove(matchingCerts);
}
installer.install(caCert);
FirefoxCertificateInstaller.install(caCert, hostNames);
} else {
// Make sure the certificate is recognized by the system
if(caCert == null) {
log.info("CA cert is empty, skipping installation checks. This is normal for trusted/3rd-party SSL certificates.");
} else {
File tempCert = File.createTempFile(KeyPairWrapper.getAlias(KeyPairWrapper.Type.CA) + "-", CertificateManager.DEFAULT_CERTIFICATE_EXTENSION);
CertificateManager.writeCert(caCert, tempCert); // temp cert
if (!installer.verify(tempCert)) {
installer.install(caCert);
FirefoxCertificateInstaller.install(caCert, hostNames);
}
if(!tempCert.delete()) {
tempCert.deleteOnExit();
}
}
}
}
catch(Exception e) {
log.error("Something went wrong obtaining the certificate. HTTPS will fail.", e);
}
// Add provisioning steps that come after certgen
if(SystemUtilities.isAdmin()) {
invokeProvisioning(Phase.CERTGEN);
}
return certificateManager;
}
/**
* Remove matching certs from user|system, then Firefox
*/
public Installer removeCerts() {
// System certs
NativeCertificateInstaller instance = NativeCertificateInstaller.getInstance();
instance.remove(instance.find());
// Firefox certs
FirefoxCertificateInstaller.uninstall();
return this;
}
/**
* Add user-specific settings
* Note: See override usage for platform-specific tasks
*/
public Installer addUserSettings() {
// Check for whitelisted certificates in <install>/whitelist/
Path whiteList = SystemUtilities.getJarParentPath().resolve(WHITELIST_CERT_DIR);
if(Files.exists(whiteList) && Files.isDirectory(whiteList)) {
for(File file : whiteList.toFile().listFiles()) {
try {
Certificate cert = new Certificate(FileUtilities.readLocalFile(file.getPath()));
if (!cert.isSaved()) {
FileUtilities.addToCertList(ALLOW_FILE, file);
}
} catch(Exception e) {
log.warn("Could not add {} to {}", file, ALLOW_FILE, e);
}
}
}
return instance;
}
public Installer invokeProvisioning(Phase phase) {
try {
Path provisionPath = SystemUtilities.isMac() ?
Paths.get(getDestination()).resolve("Contents/Resources").resolve(PROVISION_DIR) :
Paths.get(getDestination()).resolve(PROVISION_DIR);
ProvisionInstaller provisionInstaller = new ProvisionInstaller(provisionPath);
provisionInstaller.invoke(phase);
// Special case for custom websocket ports
if(phase == Phase.INSTALL) {
websocketPorts = WebsocketPorts.parseFromSteps(provisionInstaller.getSteps());
}
} catch(Exception e) {
log.warn("An error occurred invoking provision \"phase\": \"{}\"", phase, e);
}
return this;
}
public Installer removeProvisioning() {
try {
Path provisionPath = SystemUtilities.isMac() ?
Paths.get(getDestination()).resolve("Contents/Resources").resolve(PROVISION_DIR) :
Paths.get(getDestination()).resolve(PROVISION_DIR);
FileUtils.deleteDirectory(provisionPath.toFile());
} catch(Exception e) {
log.warn("An error occurred removing provision directory", e);
}
return this;
}
public static Properties persistProperties(File oldFile, Properties newProps) {
if(oldFile.exists()) {
Properties oldProps = new Properties();
try(Reader reader = new FileReader(oldFile)) {
oldProps.load(reader);
for(String key : PERSIST_PROPS) {
if (oldProps.containsKey(key)) {
String value = oldProps.getProperty(key);
log.info("Preserving {}={} for install", key, value);
newProps.put(key, value);
}
}
} catch(IOException e) {
log.warn("Warning, an error occurred reading the old properties file {}", oldFile, e);
}
}
return newProps;
}
public void spawn(String ... args) throws Exception {
spawn(new ArrayList(Arrays.asList(args)));
}
}

View File

@@ -0,0 +1,371 @@
package qz.installer;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.FileUtilities;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import qz.utils.UnixUtilities;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.regex.Pattern;
import static qz.common.Constants.*;
public class LinuxInstaller extends Installer {
protected static final Logger log = LogManager.getLogger(LinuxInstaller.class);
public static final String SHORTCUT_NAME = PROPS_FILE + ".desktop";
public static final String STARTUP_DIR = "/etc/xdg/autostart/";
public static final String STARTUP_LAUNCHER = STARTUP_DIR + SHORTCUT_NAME;
public static final String APP_DIR = "/usr/share/applications/";
public static final String APP_LAUNCHER = APP_DIR + SHORTCUT_NAME;
public static final String UDEV_RULES = "/lib/udev/rules.d/99-udev-override.rules";
public static final String[] CHROME_POLICY_DIRS = {"/etc/chromium/policies/managed", "/etc/opt/chrome/policies/managed" };
public static final String CHROME_POLICY = "{ \"URLAllowlist\": [\"" + DATA_DIR + "://*\"] }";
private String destination = "/opt/" + PROPS_FILE;
private String sudoer;
public LinuxInstaller() {
super();
sudoer = getSudoer();
}
public void setDestination(String destination) {
this.destination = destination;
}
public String getDestination() {
return destination;
}
public Installer addAppLauncher() {
addLauncher(APP_LAUNCHER, false);
return this;
}
public Installer addStartupEntry() {
addLauncher(STARTUP_LAUNCHER, true);
return this;
}
private void addLauncher(String location, boolean isStartup) {
HashMap<String, String> fieldMap = new HashMap<>();
// Dynamic fields
fieldMap.put("%DESTINATION%", destination);
fieldMap.put("%LINUX_ICON%", String.format("%s.svg", PROPS_FILE));
fieldMap.put("%COMMAND%", String.format("%s/%s", destination, PROPS_FILE));
fieldMap.put("%PARAM%", isStartup ? "--honorautostart" : "%u");
File launcher = new File(location);
try {
FileUtilities.configureAssetFile("assets/linux-shortcut.desktop.in", launcher, fieldMap, LinuxInstaller.class);
launcher.setReadable(true, false);
launcher.setExecutable(true, false);
} catch(IOException e) {
log.warn("Unable to write {} file: {}", isStartup ? "startup":"launcher", location, e);
}
}
public Installer removeLegacyStartup() {
log.info("Removing legacy autostart entries for all users matching {} or {}", ABOUT_TITLE, PROPS_FILE);
// assume users are in /home
String[] shortcutNames = {ABOUT_TITLE, PROPS_FILE};
for(File file : new File("/home").listFiles()) {
if (file.isDirectory()) {
File userStart = new File(file.getPath() + "/.config/autostart");
if (userStart.exists() && userStart.isDirectory()) {
for (String shortcutName : shortcutNames) {
File legacyStartup = new File(userStart.getPath() + File.separator + shortcutName + ".desktop");
if(legacyStartup.exists()) {
legacyStartup.delete();
}
}
}
}
}
return this;
}
public Installer addSystemSettings() {
// Legacy Ubuntu versions only: Patch Unity to show the System Tray
if(UnixUtilities.isUbuntu()) {
ShellUtilities.execute("gsettings", "set", "com.canonical.Unity.Panel", "systray", "-whitelist", "\"['all']\"");
if(ShellUtilities.execute("killall", "-w", "unity", "-panel")) {
ShellUtilities.execute("nohup", "unity", "-panel");
}
if(ShellUtilities.execute("killall", "-w", "unity", "-2d")) {
ShellUtilities.execute("nohup", "unity", "-2d");
}
}
// Chrome protocol handler
for (String policyDir : CHROME_POLICY_DIRS) {
log.info("Installing chrome protocol handler {}/{}...", policyDir, PROPS_FILE + ".json");
try {
FileUtilities.setPermissionsParentally(Files.createDirectories(Paths.get(policyDir)), false);
} catch(IOException e) {
log.warn("An error occurred creating {}", policyDir);
}
Path policy = Paths.get(policyDir, PROPS_FILE + ".json");
try (BufferedWriter writer = new BufferedWriter(new FileWriter(policy.toFile()))){
writer.write(CHROME_POLICY);
policy.toFile().setReadable(true, false);
}
catch(IOException e) {
log.warn("Unable to write chrome policy: {} ({}:launch will fail)", policy, DATA_DIR);
}
}
// USB permissions
try {
File udev = new File(UDEV_RULES);
if (udev.exists()) {
udev.delete();
}
FileUtilities.configureAssetFile("assets/linux-udev.rules.in", new File(UDEV_RULES), new HashMap<>(), LinuxInstaller.class);
// udev rules should be -rw-r--r--
udev.setReadable(true, false);
ShellUtilities.execute("udevadm", "control", "--reload-rules");
} catch(IOException e) {
log.warn("Could not install udev rules, usb support may fail {}", UDEV_RULES, e);
}
// Cleanup incorrectly placed files
File badFirefoxJs = new File("/usr/bin/defaults/pref/" + PROPS_FILE + ".js");
File badFirefoxCfg = new File("/usr/bin/" + PROPS_FILE + ".cfg");
if(badFirefoxCfg.exists()) {
log.info("Removing incorrectly placed Firefox configuration {}, {}...", badFirefoxJs, badFirefoxCfg);
badFirefoxCfg.delete();
new File("/usr/bin/defaults").delete();
}
// Cleanup incorrectly placed files
File badFirefoxPolicy = new File("/usr/bin/distribution/policies.json");
if(badFirefoxPolicy.exists()) {
log.info("Removing incorrectly placed Firefox policy {}", badFirefoxPolicy);
badFirefoxPolicy.delete();
// Delete the distribution folder too, as long as it's empty
File badPolicyFolder = badFirefoxPolicy.getParentFile();
if(badPolicyFolder.isDirectory() && badPolicyFolder.listFiles().length == 0) {
badPolicyFolder.delete();
}
}
// Cleanup
log.info("Cleaning up any remaining files...");
new File(destination + File.separator + "install").delete();
return this;
}
public Installer removeSystemSettings() {
// Chrome protocol handler
for (String policyDir : CHROME_POLICY_DIRS) {
log.info("Removing chrome protocol handler {}/{}...", policyDir, PROPS_FILE + ".json");
Path policy = Paths.get(policyDir, PROPS_FILE + ".json");
policy.toFile().delete();
}
// USB permissions
File udev = new File(UDEV_RULES);
if (udev.exists()) {
udev.delete();
}
return this;
}
// Environmental variables for spawning a task using sudo. Order is important.
static String[] SUDO_EXPORTS = {"USER", "HOME", "UPSTART_SESSION", "DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "XDG_CURRENT_DESKTOP", "GNOME_DESKTOP_SESSION_ID" };
/**
* Spawns the process as the underlying regular user account, preserving the environment
*/
public void spawn(List<String> args) throws Exception {
if(!SystemUtilities.isAdmin()) {
// Not admin, just run as the existing user
ShellUtilities.execute(args.toArray(new String[args.size()]));
return;
}
// Get user's environment from dbus, etc
HashMap<String, String> env = getUserEnv(sudoer);
if(env.size() == 0) {
throw new Exception("Unable to get dbus info; can't spawn instance");
}
// Prepare the environment
String[] envp = new String[env.size() + ShellUtilities.envp.length];
int i = 0;
// Keep existing env
for(String keep : ShellUtilities.envp) {
envp[i++] = keep;
}
for(String key :env.keySet()) {
envp[i++] = String.format("%s=%s", key, env.get(key));
}
// Concat "sudo|su", sudoer, "nohup", args
ArrayList<String> argsList = sudoCommand(sudoer, true, args);
// Spawn
log.info("Executing: {}", Arrays.toString(argsList.toArray()));
Runtime.getRuntime().exec(argsList.toArray(new String[argsList.size()]), envp);
}
/**
* Constructs a command to help running as another user using "sudo" or "su"
*/
public static ArrayList<String> sudoCommand(String sudoer, boolean async, List<String> cmds) {
ArrayList<String> sudo = new ArrayList<>();
if(StringUtils.isEmpty(sudoer) || !userExists(sudoer)) {
throw new UnsupportedOperationException(String.format("Parameter [sudoer: %s] is empty or the provided user was not found", sudoer));
}
if(ShellUtilities.execute("which", "sudo") // check if sudo exists
|| ShellUtilities.execute("sudo", "-u", sudoer, "-v")) { // check if user can login
// Pass directly into "sudo"
log.info("Guessing that this system prefers \"sudo\" over \"su\".");
sudo.add("sudo");
// Add calling user
sudo.add("-E"); // preserve environment
sudo.add("-u");
sudo.add(sudoer);
// Add "background" task support
if(async) {
sudo.add("nohup");
}
if(cmds != null && cmds.size() > 0) {
// Add additional commands
sudo.addAll(cmds);
}
} else {
// Build and escape for "su"
log.info("Guessing that this system prefers \"su\" over \"sudo\".");
sudo.add("su");
// Add calling user
sudo.add(sudoer);
sudo.add("-c");
// Add "background" task support
if(async) {
sudo.add("nohup");
}
if(cmds != null && cmds.size() > 0) {
// Add additional commands
sudo.addAll(Arrays.asList(StringUtils.join(cmds, "\" \"") + "\""));
}
}
return sudo;
}
/**
* Gets the most likely non-root user account that the installer is running from
*/
private static String getSudoer() {
String sudoer = ShellUtilities.executeRaw("logname").trim();
if(sudoer.isEmpty() || SystemUtilities.isSolaris()) {
sudoer = System.getenv("SUDO_USER");
}
return sudoer;
}
/**
* Uses two common POSIX techniques for testing if the provided user account exists
*/
private static boolean userExists(String user) {
return ShellUtilities.execute("id", "-u", user) ||
ShellUtilities.execute("getent", "passwd", user);
}
/**
* Attempts to extract user environment variables from the dbus process to
* allow starting a graphical application as the current user.
*
* If this fails, items such as the user's desktop theme may not be known to Java
* at runtime resulting in the Swing L&F instead of the Gtk L&F.
*/
private static HashMap<String, String> getUserEnv(String matchingUser) {
if(!SystemUtilities.isAdmin()) {
throw new UnsupportedOperationException("Administrative access is required");
}
String[] dbusMatches = { "ibus-daemon.*--panel", "dbus-daemon.*--config-file="};
ArrayList<String> pids = new ArrayList<>();
for(String dbusMatch : dbusMatches) {
pids.addAll(Arrays.asList(ShellUtilities.executeRaw("pgrep", "-f", dbusMatch).split("\\r?\\n")));
}
HashMap<String, String> env = new HashMap<>();
HashMap<String, String> tempEnv = new HashMap<>();
ArrayList<String> toExport = new ArrayList<>(Arrays.asList(SUDO_EXPORTS));
for(String pid : pids) {
if(pid.isEmpty()) {
continue;
}
try {
String[] vars;
if(SystemUtilities.isSolaris()) {
// Use pargs -e $$ to get environment
log.info("Reading environment info from [pargs, -e, {}]", pid);
String pargs = ShellUtilities.executeRaw("pargs", "-e", pid);
vars = pargs.split("\\r?\\n");
String delim = "]: ";
for(int i = 0; i < vars.length; i++) {
if(vars[i].contains(delim)) {
vars[i] = vars[i].substring(vars[i].indexOf(delim) + delim.length()).trim();
}
}
} else {
// Assume /proc/$$/environ
String environ = String.format("/proc/%s/environ", pid);
String delim = Pattern.compile("\0").pattern();
log.info("Reading environment info from {}", environ);
vars = new String(Files.readAllBytes(Paths.get(environ))).split(delim);
}
for(String var : vars) {
String[] parts = var.split("=", 2);
if(parts.length == 2) {
String key = parts[0].trim();
String val = parts[1].trim();
if(toExport.contains(key)) {
tempEnv.put(key, val);
}
}
}
} catch(Exception e) {
log.warn("An unexpected error occurred obtaining dbus info", e);
}
// Only add vars for the current user
if(matchingUser.trim().equals(tempEnv.get("USER"))) {
env.putAll(tempEnv);
} else {
log.debug("Expected USER={} but got USER={}, skipping results for {}", matchingUser, tempEnv.get("USER"), pid);
}
// Use gtk theme
if(env.containsKey("XDG_CURRENT_DESKTOP") && !env.containsKey("GNOME_DESKTOP_SESSION_ID")) {
if(env.get("XDG_CURRENT_DESKTOP").toLowerCase(Locale.ENGLISH).contains("gnome")) {
env.put("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated");
}
}
}
return env;
}
}

View File

@@ -0,0 +1,125 @@
package qz.installer;
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.FileUtilities;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import static qz.common.Constants.*;
public class MacInstaller extends Installer {
protected static final Logger log = LogManager.getLogger(MacInstaller.class);
private static final String PACKAGE_NAME = getPackageName();
public static final String LAUNCH_AGENT_PATH = String.format("/Library/LaunchAgents/%s.plist", MacInstaller.PACKAGE_NAME);
private String destination = "/Applications/" + ABOUT_TITLE + ".app";
public Installer addAppLauncher() {
// not needed; registered when "QZ Tray.app" is copied
return this;
}
public Installer addStartupEntry() {
File dest = new File(LAUNCH_AGENT_PATH);
HashMap<String, String> fieldMap = new HashMap<>();
// Dynamic fields
fieldMap.put("%PACKAGE_NAME%", PACKAGE_NAME);
fieldMap.put("%COMMAND%", String.format("%s/Contents/MacOS/%s", destination, ABOUT_TITLE));
fieldMap.put("%PARAM%", "--honorautostart");
try {
FileUtilities.configureAssetFile("assets/mac-launchagent.plist.in", dest, fieldMap, MacInstaller.class);
// Disable service until reboot
if(SystemUtilities.isMac()) {
ShellUtilities.execute("/bin/launchctl", "unload", MacInstaller.LAUNCH_AGENT_PATH);
}
} catch(IOException e) {
log.warn("Unable to write startup file: {}", dest, e);
}
return this;
}
public void setDestination(String destination) {
this.destination = destination;
}
public String getDestination() {
return destination;
}
public Installer addSystemSettings() {
// Chrome protocol handler
String plist = "/Library/Preferences/com.google.Chrome.plist";
if(ShellUtilities.execute(new String[] { "/usr/bin/defaults", "write", plist }, new String[] {DATA_DIR + "://*" }).isEmpty()) {
ShellUtilities.execute("/usr/bin/defaults", "write", plist, "URLAllowlist", "-array-add", DATA_DIR +"://*");
}
return this;
}
public Installer removeSystemSettings() {
// Remove startup entry
File dest = new File(LAUNCH_AGENT_PATH);
dest.delete();
return this;
}
/**
* Removes legacy (<= 2.0) startup entries
*/
public Installer removeLegacyStartup() {
log.info("Removing startup entries for all users matching " + ABOUT_TITLE);
String script = "tell application \"System Events\" to delete "
+ "every login item where name is \"" + ABOUT_TITLE + "\""
+ " or name is \"" + PROPS_FILE + ".jar\"";
// Run on background thread in case System Events is hung or slow to respond
final String finalScript = script;
new Thread(() -> {
ShellUtilities.executeAppleScript(finalScript);
}).run();
return this;
}
public static String getPackageName() {
String packageName;
String[] parts = ABOUT_URL.split("\\W");
if (parts.length >= 2) {
// Parse io.qz.qz-print from Constants
packageName = String.format("%s.%s.%s", parts[parts.length - 1], parts[parts.length - 2], PROPS_FILE);
} else {
// Fallback on something sane
packageName = "local." + PROPS_FILE;
}
return packageName;
}
public void spawn(List<String> args) throws Exception {
if(SystemUtilities.isAdmin()) {
// macOS unconventionally uses "$USER" during its install process
String sudoer = System.getenv("USER");
if(sudoer == null || sudoer.isEmpty() || sudoer.equals("root")) {
// Fallback, should only fire via Terminal + sudo
sudoer = ShellUtilities.executeRaw("logname").trim();
}
// Start directly without waitFor(...), avoids deadlocking
Runtime.getRuntime().exec(new String[] { "su", sudoer, "-c", "\"" + StringUtils.join(args, "\" \"") + "\""});
} else {
Runtime.getRuntime().exec(args.toArray(new String[args.size()]));
}
}
}

View File

@@ -0,0 +1,227 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2021 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import qz.utils.WindowsUtilities;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import static qz.common.Constants.PROPS_FILE;
public class TaskKiller {
protected static final Logger log = LogManager.getLogger(TaskKiller.class);
private static final String[] JAR_NAMES = {
PROPS_FILE + ".jar",
"qz.App", // v2.2.0...
"qz.ws.PrintSocketServer" // v2.0.0...v2.1.6
};
private static final String[] KILL_PID_CMD_POSIX = { "kill", "-9" };
private static final String[] KILL_PID_CMD_WIN32 = { "taskkill.exe", "/F", "/PID" };
private static final String[] KILL_PID_CMD = SystemUtilities.isWindows() ? KILL_PID_CMD_WIN32 : KILL_PID_CMD_POSIX;
/**
* Kills all QZ Tray processes, being careful not to kill itself
*/
public static boolean killAll() {
boolean success = true;
// Disable service until reboot
if(SystemUtilities.isMac()) {
ShellUtilities.execute("/bin/launchctl", "unload", MacInstaller.LAUNCH_AGENT_PATH);
}
// Use jcmd to get all java processes
HashSet<Integer> pids = findPidsJcmd();
if(!SystemUtilities.isWindows()) {
// Fallback to pgrep, needed for macOS (See JDK-8319589, JDK-8197387)
pids.addAll(findPidsPgrep());
} else if(WindowsUtilities.isSystemAccount()) {
// Fallback to powershell, needed for Windows
pids.addAll(findPidsPwsh());
}
// Careful not to kill ourselves ;)
pids.remove(SystemUtilities.getProcessId());
// Kill each PID
String[] killPid = new String[KILL_PID_CMD.length + 1];
System.arraycopy(KILL_PID_CMD, 0, killPid, 0, KILL_PID_CMD.length);
for (Integer pid : pids) {
killPid[killPid.length - 1] = pid.toString();
success = success && ShellUtilities.execute(killPid);
}
return success;
}
private static Path getJcmdPath() throws IOException {
Path jcmd;
if(SystemUtilities.isWindows()) {
jcmd = SystemUtilities.getJarParentPath().resolve("runtime/bin/jcmd.exe");
} else if (SystemUtilities.isMac()) {
jcmd = SystemUtilities.getJarParentPath().resolve("../PlugIns/Java.runtime/Contents/Home/bin/jcmd");
} else {
jcmd = SystemUtilities.getJarParentPath().resolve("runtime/bin/jcmd");
}
if(!jcmd.toFile().exists()) {
log.error("Could not find {}", jcmd);
throw new IOException("Could not find jcmd, we can't use it for detecting running instances");
}
return jcmd;
}
static final String[] PWSH_QUERY = { "powershell.exe", "-Command", "\"(Get-CimInstance Win32_Process -Filter \\\"Name = 'java.exe' OR Name = 'javaw.exe'\\\").Where({$_.CommandLine -like '*%s*'}).ProcessId\"" };
/**
* Leverage powershell.exe when run as SYSTEM to workaround https://github.com/qzind/tray/issues/1360
* TODO: Remove when jcmd is patched to work as SYSTEM account
*/
private static HashSet<Integer> findPidsPwsh() {
HashSet<Integer> foundPids = new HashSet<>();
for(String jarName : JAR_NAMES) {
String[] pwshQuery = PWSH_QUERY.clone();
int lastIndex = pwshQuery.length - 1;
// Format the last element to contain the jarName
pwshQuery[lastIndex] = String.format(pwshQuery[lastIndex], jarName);
String stdout = ShellUtilities.executeRaw(pwshQuery);
String[] lines = stdout.split("\\s*\\r?\\n");
for(String line : lines) {
if(line.trim().isEmpty()) {
// Don't try to process blank lines
continue;
}
int pid = parsePid(line);
if (pid >= 0) {
foundPids.add(pid);
} else {
log.warn("Could not parse PID value. Full line: '{}', Full output: '{}'", line, stdout);
}
}
}
return foundPids;
}
/**
* Use pgrep to fetch all PIDs to workaround https://github.com/openjdk/jdk/pull/25824
* TODO: Remove when jcmd is patched to work properly on macOS
*/
private static HashSet<Integer> findPidsPgrep() {
HashSet<Integer> foundPids = new HashSet<>();
for(String jarName : JAR_NAMES) {
String stdout = ShellUtilities.executeRaw("pgrep", "-f", jarName);
String[] lines = stdout.split("\\s*\\r?\\n");
for(String line : lines) {
if(line.trim().isEmpty()) {
// Don't try to process blank lines
continue;
}
int pid = parsePid(line);
if (pid >= 0) {
foundPids.add(pid);
} else {
log.warn("Could not parse PID value. Full line: '{}', Full output: '{}'", line, stdout);
}
}
}
return foundPids;
}
/**
* Uses jcmd to fetch all PIDs that match this product
*/
private static HashSet<Integer> findPidsJcmd() {
HashSet<Integer> foundPids = new HashSet<>();
String stdout;
String[] lines;
try {
stdout = ShellUtilities.executeRaw(getJcmdPath().toString(), "-l");
if(stdout == null) {
log.error("Error calling '{}' {}", getJcmdPath(), "-l");
return foundPids;
}
lines = stdout.split("\\r?\\n");
} catch(Exception e) {
log.error(e);
return foundPids;
}
for(String line : lines) {
if (line.trim().isEmpty()) {
// Don't try to process blank lines
continue;
}
// e.g. "35446 C:\Program Files\QZ Tray\qz-tray.jar"
String[] parts = line.split(" ", 2);
int pid = parsePid(parts);
if (pid >= 0) {
String args = parseArgs(parts);
if (args == null) {
log.warn("Found PID value '{}' but no args to match. Full line: '{}', Full output: '{}'", pid, line, stdout);
continue;
}
for(String jarName : JAR_NAMES) {
if (args.contains(jarName)) {
foundPids.add(pid);
break; // continue parent loop
}
}
} else {
log.warn("Could not parse PID value. Full line: '{}', Full output: '{}'", line, stdout);
}
}
return foundPids;
}
// Returns the second index of a String[], trimmed
private static String parseArgs(String[] input) {
if(input != null) {
if(input.length == 2) {
return input[1].trim();
}
}
return null;
}
// Parses an int value form the first index of a String[], returning -1 if something went wrong
private static int parsePid(String[] input) {
if(input != null) {
if(input.length == 2) {
return parsePid(input[0]);
}
}
return -1;
}
// Parses an int value form the provided string, returning -1 if something went wrong
private static int parsePid(String input) {
String pidString = input.trim();
if(StringUtils.isNumeric(pidString)) {
return Integer.parseInt(pidString);
}
return -1;
}
}

View File

@@ -0,0 +1,208 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer;
import com.sun.jna.platform.win32.*;
import mslinks.ShellLink;
import mslinks.ShellLinkException;
import mslinks.ShellLinkHelper;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import qz.utils.WindowsUtilities;
import qz.ws.PrintSocketServer;
import javax.swing.*;
import static qz.common.Constants.*;
import static qz.installer.WindowsSpecialFolders.*;
import static com.sun.jna.platform.win32.WinReg.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
public class WindowsInstaller extends Installer {
protected static final Logger log = LogManager.getLogger(WindowsInstaller.class);
private String destination = getDefaultDestination();
private String destinationExe = getDefaultDestination() + File.separator + PROPS_FILE + ".exe";
public void setDestination(String destination) {
this.destination = destination;
this.destinationExe = destination + File.separator + PROPS_FILE + ".exe";
}
/**
* Cycles through registry keys removing legacy (<= 2.0) startup entries
*/
public Installer removeLegacyStartup() {
log.info("Removing legacy startup entries for all users matching " + ABOUT_TITLE);
for (String user : Advapi32Util.registryGetKeys(HKEY_USERS)) {
WindowsUtilities.deleteRegValue(HKEY_USERS, user.trim() + "\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", ABOUT_TITLE);
}
try {
FileUtils.deleteQuietly(new File(STARTUP + File.separator + ABOUT_TITLE + ".lnk"));
} catch(Win32Exception ignore) {}
return this;
}
public Installer addAppLauncher() {
try {
// Delete old 2.0 launcher
FileUtils.deleteQuietly(new File(COMMON_START_MENU + File.separator + "Programs" + File.separator + ABOUT_TITLE + ".lnk"));
Path loc = Paths.get(COMMON_START_MENU.toString(), "Programs", ABOUT_TITLE);
loc.toFile().mkdirs();
String lnk = loc + File.separator + ABOUT_TITLE + ".lnk";
String exe = destination + File.separator + PROPS_FILE+ ".exe";
log.info("Creating launcher \"{}\" -> \"{}\"", lnk, exe);
ShellLinkHelper.createLink(exe, lnk);
} catch(ShellLinkException | IOException | Win32Exception e) {
log.warn("Could not create launcher", e);
}
return this;
}
public Installer addStartupEntry() {
try {
String lnk = WindowsSpecialFolders.COMMON_STARTUP + File.separator + ABOUT_TITLE + ".lnk";
String exe = destination + File.separator + PROPS_FILE+ ".exe";
log.info("Creating startup entry \"{}\" -> \"{}\"", lnk, exe);
ShellLink link = ShellLinkHelper.createLink(exe, lnk).getLink();
link.setCMDArgs("--honorautostart"); // honors auto-start preferences
} catch(ShellLinkException | IOException | Win32Exception e) {
log.warn("Could not create startup launcher", e);
}
return this;
}
public Installer removeSystemSettings() {
// Cleanup registry
WindowsUtilities.deleteRegKey(HKEY_LOCAL_MACHINE, "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\" + ABOUT_TITLE);
WindowsUtilities.deleteRegKey(HKEY_LOCAL_MACHINE, "Software\\" + ABOUT_TITLE);
WindowsUtilities.deleteRegKey(HKEY_LOCAL_MACHINE, DATA_DIR);
// Chrome protocol handler
WindowsUtilities.deleteRegData(HKEY_LOCAL_MACHINE, "SOFTWARE\\Policies\\Google\\Chrome\\URLAllowlist", String.format("%s://*", DATA_DIR));
// Deprecated Chrome protocol handler
WindowsUtilities.deleteRegData(HKEY_LOCAL_MACHINE, "SOFTWARE\\Policies\\Google\\Chrome\\URLWhitelist", String.format("%s://*", DATA_DIR));
// Cleanup launchers
for(WindowsSpecialFolders folder : new WindowsSpecialFolders[] { START_MENU, COMMON_START_MENU, DESKTOP, PUBLIC_DESKTOP, COMMON_STARTUP, RECENT }) {
try {
new File(folder + File.separator + ABOUT_TITLE + ".lnk").delete();
// Since 2.1, start menus use subfolder
if (folder.equals(COMMON_START_MENU) || folder.equals(START_MENU)) {
FileUtils.deleteQuietly(new File(folder + File.separator + "Programs" + File.separator + ABOUT_TITLE + ".lnk"));
FileUtils.deleteDirectory(new File(folder + File.separator + "Programs" + File.separator + ABOUT_TITLE));
}
} catch(InvalidPathException | IOException | Win32Exception ignore) {}
}
// Cleanup firewall rules
ShellUtilities.execute("netsh.exe", "advfirewall", "firewall", "delete", "rule", String.format("name=%s", ABOUT_TITLE));
return this;
}
public Installer addSystemSettings() {
/**
* TODO: Upgrade JNA!
* 64-bit registry view is currently invoked by nsis (windows-installer.nsi.in) using SetRegView 64
* However, newer version of JNA offer direct WinNT.KEY_WOW64_64KEY registry support, safeguarding
* against direct calls to "java -jar qz-tray.jar install|keygen|etc", which will be needed moving forward
* for support and troubleshooting.
*/
// Mime-type support e.g. qz:launch
WindowsUtilities.addRegValue(HKEY_CLASSES_ROOT, DATA_DIR, "", String.format("URL:%s Protocol", ABOUT_TITLE));
WindowsUtilities.addRegValue(HKEY_CLASSES_ROOT, DATA_DIR, "URL Protocol", "");
WindowsUtilities.addRegValue(HKEY_CLASSES_ROOT, String.format("%s\\DefaultIcon", DATA_DIR), "", String.format("\"%s\",1", destinationExe));
WindowsUtilities.addRegValue(HKEY_CLASSES_ROOT, String.format("%s\\shell\\open\\command", DATA_DIR), "", String.format("\"%s\" \"%%1\"", destinationExe));
/// Uninstall info
String uninstallKey = String.format("Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\%s", ABOUT_TITLE);
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, String.format("Software\\%s", ABOUT_TITLE), "", destination);
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "DisplayName", String.format("%s %s", ABOUT_TITLE, VERSION));
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "Publisher", ABOUT_COMPANY);
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "UninstallString", destination + File.separator + "uninstall.exe");
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "DisplayIcon", destinationExe);
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "HelpLink", ABOUT_SUPPORT_URL );
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "URLUpdateInfo", ABOUT_DOWNLOAD_URL);
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "URLInfoAbout", ABOUT_SUPPORT_URL);
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "DisplayVersion", VERSION.toString());
WindowsUtilities.addRegValue(HKEY_LOCAL_MACHINE, uninstallKey, "EstimatedSize", FileUtils.sizeOfDirectoryAsBigInteger(new File(destination)).intValue() / 1024);
// Chrome protocol handler
WindowsUtilities.addNumberedRegValue(HKEY_LOCAL_MACHINE, "SOFTWARE\\Policies\\Google\\Chrome\\URLAllowlist", String.format("%s://*", DATA_DIR));
// Firewall rules
ShellUtilities.execute("netsh.exe", "advfirewall", "firewall", "delete", "rule", String.format("name=%s", ABOUT_TITLE));
ShellUtilities.execute("netsh.exe", "advfirewall", "firewall", "add", "rule", String.format("name=%s", ABOUT_TITLE),
"dir=in", "action=allow", "profile=any", String.format("localport=%s", websocketPorts.allPortsAsString()), "localip=any", "protocol=tcp");
return this;
}
@Override
public Installer addUserSettings() {
// Whitelist loopback for IE/Edge
if(ShellUtilities.execute("CheckNetIsolation.exe", "LoopbackExempt", "-a", "-n=Microsoft.MicrosoftEdge_8wekyb3d8bbwe")) {
log.warn("Could not whitelist loopback connections for IE, Edge");
}
try {
// Intranet settings; uncheck "include sites not listed in other zones"
String key = "Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\\Zones\\1";
String value = "Flags";
if (Advapi32Util.registryKeyExists(HKEY_CURRENT_USER, key) && Advapi32Util.registryValueExists(HKEY_CURRENT_USER, key, value)) {
int data = Advapi32Util.registryGetIntValue(HKEY_CURRENT_USER, key, value);
// remove value using bitwise XOR
Advapi32Util.registrySetIntValue(HKEY_CURRENT_USER, key, value, data ^ 16);
}
// Legacy Edge loopback support
key = "Software\\Classes\\Local Settings\\Software\\Microsoft\\Windows\\CurrentVersion\\AppContainer\\Storage\\microsoft.microsoftedge_8wekyb3d8bbwe\\MicrosoftEdge\\ExperimentalFeatures";
value = "AllowLocalhostLoopback";
if (Advapi32Util.registryKeyExists(HKEY_CURRENT_USER, key) && Advapi32Util.registryValueExists(HKEY_CURRENT_USER, key, value)) {
int data = Advapi32Util.registryGetIntValue(HKEY_CURRENT_USER, key, value);
// remove value using bitwise OR
Advapi32Util.registrySetIntValue(HKEY_CURRENT_USER, key, value, data | 1);
}
} catch(Exception e) {
log.warn("An error occurred configuring the \"Local Intranet Zone\"; connections to \"localhost\" may fail", e);
}
return super.addUserSettings();
}
public static String getDefaultDestination() {
String path = System.getenv("ProgramW6432");
if (path == null || path.trim().isEmpty()) {
path = System.getenv("ProgramFiles");
}
return path + File.separator + ABOUT_TITLE;
}
public String getDestination() {
return destination;
}
public void spawn(List<String> args) throws Exception {
if(SystemUtilities.isAdmin()) {
log.warn("Spawning as user isn't implemented; starting process with elevation instead");
}
ShellUtilities.execute(args.toArray(new String[args.size()]));
}
}

View File

@@ -0,0 +1,97 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer;
import com.sun.jna.platform.win32.*;
import qz.utils.WindowsUtilities;
/**
* Windows XP-compatible special folder's wrapper for JNA
*
*/
public enum WindowsSpecialFolders {
ADMIN_TOOLS(ShlObj.CSIDL_ADMINTOOLS, KnownFolders.FOLDERID_AdminTools),
STARTUP_ALT(ShlObj.CSIDL_ALTSTARTUP, KnownFolders.FOLDERID_Startup),
ROAMING_APPDATA(ShlObj.CSIDL_APPDATA, KnownFolders.FOLDERID_RoamingAppData),
RECYCLING_BIN(ShlObj.CSIDL_BITBUCKET, KnownFolders.FOLDERID_RecycleBinFolder),
CD_BURNING(ShlObj.CSIDL_CDBURN_AREA, KnownFolders.FOLDERID_CDBurning),
COMMON_ADMIN_TOOLS(ShlObj.CSIDL_COMMON_ADMINTOOLS, KnownFolders.FOLDERID_CommonAdminTools),
COMMON_STARTUP_ALT(ShlObj.CSIDL_COMMON_ALTSTARTUP, KnownFolders.FOLDERID_CommonStartup),
PROGRAM_DATA(ShlObj.CSIDL_COMMON_APPDATA, KnownFolders.FOLDERID_ProgramData),
PUBLIC_DESKTOP(ShlObj.CSIDL_COMMON_DESKTOPDIRECTORY, KnownFolders.FOLDERID_PublicDesktop),
PUBLIC_DOCUMENTS(ShlObj.CSIDL_COMMON_DOCUMENTS, KnownFolders.FOLDERID_PublicDocuments),
COMMON_FAVORITES(ShlObj.CSIDL_COMMON_FAVORITES, KnownFolders.FOLDERID_Favorites),
COMMON_MUSIC(ShlObj.CSIDL_COMMON_MUSIC, KnownFolders.FOLDERID_PublicMusic),
COMMON_OEM_LINKS(ShlObj.CSIDL_COMMON_OEM_LINKS, KnownFolders.FOLDERID_CommonOEMLinks),
COMMON_PICTURES(ShlObj.CSIDL_COMMON_PICTURES, KnownFolders.FOLDERID_PublicPictures),
COMMON_PROGRAMS(ShlObj.CSIDL_COMMON_PROGRAMS, KnownFolders.FOLDERID_CommonPrograms),
COMMON_START_MENU(ShlObj.CSIDL_COMMON_STARTMENU, KnownFolders.FOLDERID_CommonStartMenu),
COMMON_STARTUP(ShlObj.CSIDL_COMMON_STARTUP, KnownFolders.FOLDERID_CommonStartup),
COMMON_TEMPLATES(ShlObj.CSIDL_COMMON_TEMPLATES, KnownFolders.FOLDERID_CommonTemplates),
COMMON_VIDEO(ShlObj.CSIDL_COMMON_VIDEO, KnownFolders.FOLDERID_PublicVideos),
COMPUTERS_NEAR_ME(ShlObj.CSIDL_COMPUTERSNEARME, KnownFolders.FOLDERID_NetworkFolder),
CONNECTIONS_FOLDER(ShlObj.CSIDL_CONNECTIONS, KnownFolders.FOLDERID_ConnectionsFolder),
CONTROL_PANEL(ShlObj.CSIDL_CONTROLS, KnownFolders.FOLDERID_ControlPanelFolder),
COOKIES(ShlObj.CSIDL_COOKIES, KnownFolders.FOLDERID_Cookies),
DESKTOP_VIRTUAL(ShlObj.CSIDL_DESKTOP, KnownFolders.FOLDERID_Desktop),
DESKTOP(ShlObj.CSIDL_DESKTOPDIRECTORY, KnownFolders.FOLDERID_Desktop),
COMPUTER_FOLDER(ShlObj.CSIDL_DRIVES, KnownFolders.FOLDERID_ComputerFolder),
FAVORITES(ShlObj.CSIDL_FAVORITES, KnownFolders.FOLDERID_Favorites),
FONTS(ShlObj.CSIDL_FONTS, KnownFolders.FOLDERID_Fonts),
HISTORY(ShlObj.CSIDL_HISTORY, KnownFolders.FOLDERID_History),
INTERNET_FOLDER(ShlObj.CSIDL_INTERNET, KnownFolders.FOLDERID_InternetFolder),
INTERNET_CACHE(ShlObj.CSIDL_INTERNET_CACHE, KnownFolders.FOLDERID_InternetCache),
LOCAL_APPDATA(ShlObj.CSIDL_LOCAL_APPDATA, KnownFolders.FOLDERID_LocalAppData),
MY_DOCUMENTS(ShlObj.CSIDL_MYDOCUMENTS, KnownFolders.FOLDERID_Documents),
MY_MUSIC(ShlObj.CSIDL_MYMUSIC, KnownFolders.FOLDERID_Music),
MY_PICTURES(ShlObj.CSIDL_MYPICTURES, KnownFolders.FOLDERID_Pictures),
MY_VIDEOS(ShlObj.CSIDL_MYVIDEO, KnownFolders.FOLDERID_Videos),
NETWORK_NEIGHBORHOOD(ShlObj.CSIDL_NETHOOD, KnownFolders.FOLDERID_NetHood),
NETWORK_FOLDER(ShlObj.CSIDL_NETWORK, KnownFolders.FOLDERID_NetworkFolder),
PERSONAL_FOLDDER(ShlObj.CSIDL_PERSONAL, KnownFolders.FOLDERID_Documents),
PRINTERS(ShlObj.CSIDL_PRINTERS, KnownFolders.FOLDERID_PrintersFolder),
PRINTING_NEIGHBORHOODD(ShlObj.CSIDL_PRINTHOOD, KnownFolders.FOLDERID_PrintHood),
PROFILE_FOLDER(ShlObj.CSIDL_PROFILE, KnownFolders.FOLDERID_Profile),
PROGRAM_FILES(ShlObj.CSIDL_PROGRAM_FILES, KnownFolders.FOLDERID_ProgramFiles),
PROGRAM_FILESX86(ShlObj.CSIDL_PROGRAM_FILESX86, KnownFolders.FOLDERID_ProgramFilesX86),
PROGRAM_FILES_COMMON(ShlObj.CSIDL_PROGRAM_FILES_COMMON, KnownFolders.FOLDERID_ProgramFilesCommon),
PROGRAM_FILES_COMMONX86(ShlObj.CSIDL_PROGRAM_FILES_COMMONX86, KnownFolders.FOLDERID_ProgramFilesCommonX86),
PROGRAMS(ShlObj.CSIDL_PROGRAMS, KnownFolders.FOLDERID_Programs),
RECENT(ShlObj.CSIDL_RECENT, KnownFolders.FOLDERID_Recent),
RESOURCES(ShlObj.CSIDL_RESOURCES, KnownFolders.FOLDERID_ResourceDir),
RESOURCES_LOCALIZED(ShlObj.CSIDL_RESOURCES_LOCALIZED, KnownFolders.FOLDERID_LocalizedResourcesDir),
SEND_TO(ShlObj.CSIDL_SENDTO, KnownFolders.FOLDERID_SendTo),
START_MENU(ShlObj.CSIDL_STARTMENU, KnownFolders.FOLDERID_StartMenu),
STARTUP(ShlObj.CSIDL_STARTUP, KnownFolders.FOLDERID_Startup),
SYSTEM(ShlObj.CSIDL_SYSTEM, KnownFolders.FOLDERID_System),
SYSTEMX86(ShlObj.CSIDL_SYSTEMX86, KnownFolders.FOLDERID_SystemX86),
TEMPLATES(ShlObj.CSIDL_TEMPLATES, KnownFolders.FOLDERID_Templates),
WINDOWS(ShlObj.CSIDL_WINDOWS, KnownFolders.FOLDERID_Windows);
private int csidl;
private Guid.GUID guid;
WindowsSpecialFolders(int csidl, Guid.GUID guid) {
this.csidl = csidl;
this.guid = guid;
}
public String getPath() {
if(WindowsUtilities.isWindowsXP()) {
return Shell32Util.getSpecialFolderPath(csidl, false);
}
return Shell32Util.getKnownFolderPath(guid);
}
@Override
public String toString() {
return getPath();
}
}

View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=%ABOUT_TITLE%
Exec="%COMMAND%" %PARAM%
Path=%DESTINATION%
Icon=%DESTINATION%/%LINUX_ICON%
MimeType=application/x-qz;x-scheme-handler/qz;
Terminal=false

View File

@@ -0,0 +1,2 @@
# %ABOUT_TITLE% usb override settings
SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", MODE="0666"

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>%PACKAGE_NAME%</string>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key><false/>
<key>AfterInitialDemand</key><false/>
</dict>
<key>RunAtLoad</key><true/>
<key>ProgramArguments</key>
<array>
<string>%COMMAND%</string>
<string>%PARAM%</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,147 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate;
import java.io.IOException;
import java.math.BigInteger;
import java.security.*;
import java.util.Calendar;
import java.util.Locale;
import org.bouncycastle.asn1.*;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.X500NameBuilder;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x509.*;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import qz.common.Constants;
import qz.utils.SystemUtilities;
import static qz.installer.certificate.KeyPairWrapper.Type.*;
public class CertificateChainBuilder {
public static final String[] DEFAULT_HOSTNAMES = {"localhost", "localhost.qz.io" };
private static int KEY_SIZE = 2048;
public static int CA_CERT_AGE = 7305; // 20 years
public static int SSL_CERT_AGE = 825; // Per https://support.apple.com/HT210176
private String[] hostNames;
public CertificateChainBuilder(String ... hostNames) {
Security.addProvider(new BouncyCastleProvider());
if(hostNames.length > 0) {
this.hostNames = hostNames;
} else {
this.hostNames = DEFAULT_HOSTNAMES;
}
}
public KeyPairWrapper createCaCert() throws IOException, GeneralSecurityException, OperatorException {
KeyPair keyPair = createRsaKey();
X509v3CertificateBuilder builder = createX509Cert(keyPair, CA_CERT_AGE, hostNames);
builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(1))
.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign + KeyUsage.cRLSign))
.addExtension(Extension.subjectKeyIdentifier, false, new JcaX509ExtensionUtils().createSubjectKeyIdentifier(keyPair.getPublic()));
// Signing
ContentSigner sign = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC").build(keyPair.getPrivate());
X509CertificateHolder certHolder = builder.build(sign);
// Convert to java-friendly format
return new KeyPairWrapper(CA, keyPair, new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder));
}
public KeyPairWrapper createSslCert(KeyPairWrapper caKeyPairWrapper) throws IOException, GeneralSecurityException, OperatorException {
KeyPair sslKeyPair = createRsaKey();
X509v3CertificateBuilder builder = createX509Cert(sslKeyPair, SSL_CERT_AGE, hostNames);
JcaX509ExtensionUtils utils = new JcaX509ExtensionUtils();
builder.addExtension(Extension.authorityKeyIdentifier, false, utils.createAuthorityKeyIdentifier(caKeyPairWrapper.getCert()))
.addExtension(Extension.basicConstraints, true, new BasicConstraints(false))
.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature + KeyUsage.keyEncipherment))
.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth}))
.addExtension(Extension.subjectAlternativeName, false, buildSan(hostNames))
.addExtension(Extension.subjectKeyIdentifier, false, utils.createSubjectKeyIdentifier(sslKeyPair.getPublic()));
// Signing
ContentSigner sign = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC").build(caKeyPairWrapper.getKey());
X509CertificateHolder certHolder = builder.build(sign);
// Convert to java-friendly format
return new KeyPairWrapper(SSL, sslKeyPair, new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder));
}
private static KeyPair createRsaKey() throws GeneralSecurityException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(KEY_SIZE, new SecureRandom());
return keyPairGenerator.generateKeyPair();
}
private static X509v3CertificateBuilder createX509Cert(KeyPair keyPair, int age, String ... hostNames) {
String cn = hostNames.length > 0? hostNames[0]:DEFAULT_HOSTNAMES[0];
X500Name name = new X500NameBuilder()
.addRDN(BCStyle.C, Constants.ABOUT_COUNTRY)
.addRDN(BCStyle.ST, Constants.ABOUT_STATE)
.addRDN(BCStyle.L, Constants.ABOUT_CITY)
.addRDN(BCStyle.O, Constants.ABOUT_COMPANY)
.addRDN(BCStyle.OU, Constants.ABOUT_COMPANY)
.addRDN(BCStyle.EmailAddress, Constants.ABOUT_EMAIL)
.addRDN(BCStyle.CN, cn)
.build();
BigInteger serial = BigInteger.valueOf(System.currentTimeMillis());
Calendar notBefore = Calendar.getInstance(Locale.ENGLISH);
Calendar notAfter = Calendar.getInstance(Locale.ENGLISH);
notBefore.add(Calendar.DAY_OF_YEAR, -1);
notAfter.add(Calendar.DAY_OF_YEAR, age - 1);
SystemUtilities.swapLocale();
X509v3CertificateBuilder x509builder = new JcaX509v3CertificateBuilder(name, serial, notBefore.getTime(), notAfter.getTime(), name, keyPair.getPublic());
SystemUtilities.restoreLocale();
return x509builder;
}
/**
* Builds subjectAlternativeName extension; iterates and detects IPv4 or hostname
*/
private static GeneralNames buildSan(String ... hostNames) {
GeneralName[] gn = new GeneralName[hostNames.length];
for (int i = 0; i < hostNames.length; i++) {
int gnType = isIp(hostNames[i]) ? GeneralName.iPAddress : GeneralName.dNSName;
gn[i] = new GeneralName(gnType, hostNames[i]);
}
return GeneralNames.getInstance(new DERSequence(gn));
}
private static boolean isIp(String ip) {
try {
String[] split = ip.split("\\.");
if (split.length != 4) return false;
for (int i = 0; i < 4; ++i) {
int p = Integer.parseInt(split[i]);
if (p > 255 || p < 0) return false;
}
return true;
} catch (Exception ignore) {}
return false;
}
}

View File

@@ -0,0 +1,478 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.asn1.x500.AttributeTypeAndValue;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.operator.OperatorException;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.installer.Installer;
import qz.utils.ArgValue;
import qz.utils.FileUtilities;
import qz.utils.MacUtilities;
import qz.utils.SystemUtilities;
import java.io.*;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.security.*;
import java.security.cert.X509Certificate;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.*;
import static qz.utils.FileUtilities.*;
import static qz.installer.certificate.KeyPairWrapper.Type.*;
/**
* Stores and maintains reading and writing of certificate related files
*/
public class CertificateManager {
static List<Path> SAVE_LOCATIONS = new ArrayList<>();
static {
// Workaround for JDK-8266929
// See also https://github.com/qzind/tray/issues/814
SystemUtilities.clearAlgorithms();
// Skip shared location if running from IDE or build directory
// Prevents corrupting the version installed per https://github.com/qzind/tray/issues/1200
if(SystemUtilities.isJar() && SystemUtilities.isInstalled()) {
// Skip install location if running from sandbox (must remain sealed)
if(!SystemUtilities.isMac() || !MacUtilities.isSandboxed()) {
SAVE_LOCATIONS.add(SystemUtilities.getJarParentPath());
}
SAVE_LOCATIONS.add(SHARED_DIR);
}
SAVE_LOCATIONS.add(USER_DIR);
}
private static final Logger log = LogManager.getLogger(CertificateManager.class);
public static String DEFAULT_KEYSTORE_FORMAT = "PKCS12";
public static String DEFAULT_KEYSTORE_EXTENSION = ".p12";
public static String DEFAULT_CERTIFICATE_EXTENSION = ".crt";
private static int DEFAULT_PASSWORD_BITS = 100;
private boolean needsInstall;
private SslContextFactory.Server sslContextFactory;
private KeyPairWrapper sslKeyPair;
private KeyPairWrapper caKeyPair;
private Properties properties;
private char[] password;
/**
* For internal certs
*/
public CertificateManager(boolean forceNew, String ... hostNames) throws IOException, GeneralSecurityException, OperatorException {
Security.addProvider(new BouncyCastleProvider());
sslKeyPair = new KeyPairWrapper(SSL);
caKeyPair = new KeyPairWrapper(CA);
if (!forceNew) {
// order is important: ssl, ca
properties = loadProperties(sslKeyPair, caKeyPair);
}
if(properties == null) {
log.warn("Warning, SSL properties won't be loaded from disk... we'll try to create them...");
CertificateChainBuilder cb = new CertificateChainBuilder(hostNames);
caKeyPair = cb.createCaCert();
sslKeyPair = cb.createSslCert(caKeyPair);
// Create CA
properties = createKeyStore(CA)
.writeCert(CA)
.writeKeystore(null, CA);
// Create SSL
properties = createKeyStore(SSL)
.writeCert(SSL)
.writeKeystore(properties, SSL);
// Save properties
saveProperties();
}
}
/**
* For trusted PEM-formatted certs
*/
public CertificateManager(File trustedPemKey, File trustedPemCert) throws Exception {
Security.addProvider(new BouncyCastleProvider());
needsInstall = false;
sslKeyPair = new KeyPairWrapper(SSL);
// Assumes ssl/privkey.pem, ssl/fullchain.pem
properties = createTrustedKeystore(trustedPemKey, trustedPemCert)
.writeKeystore(properties, SSL);
// Save properties
saveProperties();
}
/**
* For trusted PKCS12-formatted certs
*/
public CertificateManager(File pkcs12File, char[] password) throws Exception {
Security.addProvider(new BouncyCastleProvider());
needsInstall = false;
sslKeyPair = new KeyPairWrapper(SSL);
// Assumes direct pkcs12 import
this.password = password;
sslKeyPair.init(pkcs12File, password);
// Save it back, but to a location we can find
properties = writeKeystore(null, SSL);
// Save properties
saveProperties();
}
public void renewCertChain(String ... hostNames) throws Exception {
CertificateChainBuilder cb = new CertificateChainBuilder(hostNames);
sslKeyPair = cb.createSslCert(caKeyPair);
createKeyStore(SSL).writeKeystore(properties, SSL);
reloadSslContextFactory();
}
public KeyPairWrapper getSslKeyPair() {
return sslKeyPair;
}
public KeyPairWrapper getCaKeyPair() {
return caKeyPair;
}
public KeyPairWrapper getKeyPair(KeyPairWrapper.Type type) {
switch(type) {
case SSL:
return sslKeyPair;
case CA:
default:
return caKeyPair;
}
}
public KeyPairWrapper getKeyPair(String alias) {
for(KeyPairWrapper.Type type : KeyPairWrapper.Type.values()) {
if (KeyPairWrapper.getAlias(type).equalsIgnoreCase(alias)) {
return getKeyPair(type);
}
}
return getKeyPair(KeyPairWrapper.Type.CA);
}
public Properties getProperties() {
return properties;
}
private char[] getPassword() {
if (password == null) {
if(caKeyPair != null && caKeyPair.getPassword() != null) {
// Reuse existing
password = caKeyPair.getPassword();
} else {
// Create new
BigInteger bi = new BigInteger(DEFAULT_PASSWORD_BITS, new SecureRandom());
password = bi.toString(16).toCharArray();
log.info("Created a random {} bit password: {}", DEFAULT_PASSWORD_BITS, new String(password));
}
}
return password;
}
public SslContextFactory.Server configureSslContextFactory() {
sslContextFactory = new SslContextFactory.Server();
sslContextFactory.setKeyStore(sslKeyPair.getKeyStore());
sslContextFactory.setKeyStorePassword(sslKeyPair.getPasswordString());
sslContextFactory.setKeyManagerPassword(sslKeyPair.getPasswordString());
return sslContextFactory;
}
public void reloadSslContextFactory() throws Exception {
if(isSslActive()) {
sslContextFactory.reload(sslContextFactory -> {
sslContextFactory.setKeyStore(sslKeyPair.getKeyStore());
sslContextFactory.setKeyStorePassword(sslKeyPair.getPasswordString());
sslContextFactory.setKeyManagerPassword(sslKeyPair.getPasswordString());
});
} else {
log.warn("SSL isn't active, can't reload");
}
}
public boolean isSslActive() {
return sslContextFactory != null;
}
public boolean needsInstall() {
return needsInstall;
}
public CertificateManager createKeyStore(KeyPairWrapper.Type type) throws IOException, GeneralSecurityException {
KeyPairWrapper keyPair = type == CA ? caKeyPair : sslKeyPair;
KeyStore keyStore = KeyStore.getInstance(DEFAULT_KEYSTORE_FORMAT);
keyStore.load(null, password);
List<X509Certificate> chain = new ArrayList<>();
chain.add(keyPair.getCert());
// Add ca to ssl cert chain
if (keyPair.getType() == SSL) {
chain.add(caKeyPair.getCert());
}
keyStore.setEntry(caKeyPair.getAlias(), new KeyStore.TrustedCertificateEntry(caKeyPair.getCert()), null);
keyStore.setKeyEntry(keyPair.getAlias(), keyPair.getKey(), getPassword(), chain.toArray(new X509Certificate[chain.size()]));
keyPair.init(keyStore, getPassword());
return this;
}
public CertificateManager createTrustedKeystore(File p12Store, String password) throws Exception {
sslKeyPair = new KeyPairWrapper(SSL);
sslKeyPair.init(p12Store, password.toCharArray());
return this;
}
public CertificateManager createTrustedKeystore(File pemKey, File pemCert) throws Exception {
sslKeyPair = new KeyPairWrapper(SSL);
// Private Key
PEMParser pem = new PEMParser(new FileReader(pemKey));
Object parsedObject = pem.readObject();
PrivateKeyInfo privateKeyInfo = parsedObject instanceof PEMKeyPair ? ((PEMKeyPair)parsedObject).getPrivateKeyInfo() : (PrivateKeyInfo)parsedObject;
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(privateKeyInfo.getEncoded());
KeyFactory factory = KeyFactory.getInstance("RSA");
PrivateKey key = factory.generatePrivate(privateKeySpec);
List<X509Certificate> certs = new ArrayList<>();
X509CertificateHolder certHolder = (X509CertificateHolder)pem.readObject();
if(certHolder != null) {
certs.add(new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder));
}
// Certificate
pem = new PEMParser(new FileReader(pemCert));
while((certHolder = (X509CertificateHolder)pem.readObject()) != null) {
certs.add(new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder));
}
// Keystore
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null);
for (int i = 0; i < certs.size(); i++) {
ks.setCertificateEntry(sslKeyPair.getAlias() + "_" + i, certs.get(i));
}
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null);
keyStore.setKeyEntry(sslKeyPair.getAlias(), key, getPassword(), certs.toArray(new X509Certificate[certs.size()]));
sslKeyPair.init(keyStore, getPassword());
return this;
}
public static void writeCert(X509Certificate data, File dest) throws IOException {
// PEMWriter doesn't always clear the file, explicitly delete it, see issue #796
if(dest.exists()) {
dest.delete();
}
JcaMiscPEMGenerator cert = new JcaMiscPEMGenerator(data);
JcaPEMWriter writer = new JcaPEMWriter(new OutputStreamWriter(Files.newOutputStream(dest.toPath(), StandardOpenOption.CREATE)));
writer.writeObject(cert.generate());
writer.close();
FileUtilities.inheritParentPermissions(dest.toPath());
log.info("Wrote Cert: \"{}\"", dest);
}
public CertificateManager writeCert(KeyPairWrapper.Type type) throws IOException {
KeyPairWrapper keyPair = type == CA ? caKeyPair : sslKeyPair;
File certFile = new File(getWritableLocation("ssl"), keyPair.getAlias() + DEFAULT_CERTIFICATE_EXTENSION);
writeCert(keyPair.getCert(), certFile);
FileUtilities.inheritParentPermissions(certFile.toPath());
if(keyPair.getType() == CA) {
needsInstall = true;
}
return this;
}
public Properties writeKeystore(Properties props, KeyPairWrapper.Type type) throws GeneralSecurityException, IOException {
File sslDir = getWritableLocation("ssl");
KeyPairWrapper keyPair = type == CA ? caKeyPair : sslKeyPair;
File keyFile = new File(sslDir, keyPair.getAlias() + DEFAULT_KEYSTORE_EXTENSION);
keyPair.getKeyStore().store(Files.newOutputStream(keyFile.toPath(), StandardOpenOption.CREATE), getPassword());
FileUtilities.inheritParentPermissions(keyFile.toPath());
log.info("Wrote {} Key: \"{}\"", DEFAULT_KEYSTORE_FORMAT, keyFile);
if (props == null) {
props = new Properties();
}
props.putIfAbsent(String.format("%s.keystore", keyPair.propsPrefix()), keyFile.toString());
props.putIfAbsent(String.format("%s.storepass", keyPair.propsPrefix()), new String(getPassword()));
props.putIfAbsent(String.format("%s.alias", keyPair.propsPrefix()), keyPair.getAlias());
if (keyPair.getType() == SSL) {
props.putIfAbsent(String.format("%s.host", keyPair.propsPrefix()), ArgValue.SECURITY_WSS_HOST.getDefaultVal());
}
return props;
}
public static File getWritableLocation(String ... suffixes) throws IOException {
// Get an array of preferred directories
ArrayList<Path> locs = new ArrayList<>();
if (suffixes.length == 0) {
locs.addAll(SAVE_LOCATIONS);
// Last, fallback on a directory we won't ever see again :/
locs.add(TEMP_DIR);
} else {
// Same as above, but with suffixes added (usually "ssl"), skipping the install location
for(Path saveLocation : SAVE_LOCATIONS) {
if(!saveLocation.equals(SystemUtilities.getJarParentPath())) {
locs.add(Paths.get(saveLocation.toString(), suffixes));
}
}
// Last, fallback on a directory we won't ever see again :/
locs.add(Paths.get(TEMP_DIR.toString(), suffixes));
}
// Find a suitable write location
File path;
for(Path loc : locs) {
if (loc == null) continue;
boolean isPreferred = locs.indexOf(loc) == 0;
path = loc.toFile();
path.mkdirs();
if (path.canWrite()) {
log.debug("Writing to {}", loc);
if(!isPreferred) {
log.warn("Warning, {} isn't the preferred write location, but we'll use it anyway", loc);
}
return path;
} else {
log.debug("Can't write to {}, trying the next...", loc);
}
}
throw new IOException("Can't find a suitable write location. SSL will fail.");
}
public static Properties loadProperties(KeyPairWrapper... keyPairs) {
log.info("Try to find SSL properties file...");
Properties props = null;
for(Path loc : SAVE_LOCATIONS) {
if (loc == null) continue;
try {
for(KeyPairWrapper keyPair : keyPairs) {
props = loadKeyPair(keyPair, loc, props);
}
// We've loaded without Exception, return
log.info("Found {}/{}.properties", loc, Constants.PROPS_FILE);
return props;
} catch(Exception ignore) {
log.warn("Properties couldn't be loaded at {}, trying fallback...", loc, ignore);
}
}
log.info("Could not get SSL properties from file.");
return null;
}
public static Properties loadKeyPair(KeyPairWrapper keyPair, Path parent, Properties existing) throws Exception {
Properties props;
if (existing == null) {
FileInputStream fis = null;
try {
props = new Properties();
props.load(fis = new FileInputStream(new File(parent.toFile(), Constants.PROPS_FILE + ".properties")));
} finally {
if(fis != null) fis.close();
}
} else {
props = existing;
}
String ks = props.getProperty(String.format("%s.keystore", keyPair.propsPrefix()));
String pw = props.getProperty(String.format("%s.storepass", keyPair.propsPrefix()), "");
if(ks == null || ks.trim().isEmpty()) {
if(keyPair.getType() == SSL) {
throw new IOException("Missing wss.keystore entry");
} else {
// CA is only needed for internal certs, return
return props;
}
}
File ksFile = Paths.get(ks).isAbsolute()? new File(ks):new File(parent.toFile(), ks);
if (ksFile.exists()) {
keyPair.init(ksFile, pw.toCharArray());
return props;
}
return null;
}
private void saveProperties() throws IOException {
File propsFile = new File(getWritableLocation(), Constants.PROPS_FILE + ".properties");
Installer.persistProperties(propsFile, properties); // checks for props from previous install
properties.store(new FileOutputStream(propsFile), null);
FileUtilities.inheritParentPermissions(propsFile.toPath());
log.info("Successfully created SSL properties file: {}", propsFile);
}
public static boolean emailMatches(X509Certificate cert) {
return emailMatches(cert, false);
}
public static boolean emailMatches(X509Certificate cert, boolean quiet) {
try {
X500Name x500name = new JcaX509CertificateHolder(cert).getSubject();
RDN[] emailNames = x500name.getRDNs(BCStyle.E);
for(RDN emailName : emailNames) {
AttributeTypeAndValue first = emailName.getFirst();
if (first != null && first.getValue() != null && Constants.ABOUT_EMAIL.equals(first.getValue().toString())) {
if(!quiet) {
log.info("Email address {} found, assuming CertProvider is {}", Constants.ABOUT_EMAIL, ExpiryTask.CertProvider.INTERNAL);
}
return true;
}
}
}
catch(Exception ignore) {}
if(!quiet) {
log.info("Email address {} was not found. Assuming the certificate is manually installed, we won't try to renew it.", Constants.ABOUT_EMAIL);
}
return false;
}
}

View File

@@ -0,0 +1,295 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.*;
import static qz.utils.FileUtilities.*;
public class ExpiryTask extends TimerTask {
private static final Logger log = LogManager.getLogger(CertificateManager.class);
public static final int DEFAULT_INITIAL_DELAY = 60 * 1000; // 1 minute
public static final int DEFAULT_CHECK_FREQUENCY = 3600 * 1000; // 1 hour
private static final int DEFAULT_GRACE_PERIOD_DAYS = 5;
private enum ExpiryState {VALID, EXPIRING, EXPIRED, MANAGED}
public enum CertProvider {
INTERNAL(Constants.ABOUT_COMPANY + ".*"),
LETS_ENCRYPT("Let's Encrypt.*"),
CA_CERT_ORG("CA Cert Signing.*"),
UNKNOWN;
String[] patterns;
CertProvider(String ... regexPattern) {
this.patterns = regexPattern;
}
}
private Timer timer;
private CertificateManager certificateManager;
private String[] hostNames;
private CertProvider certProvider;
public ExpiryTask(CertificateManager certificateManager) {
super();
this.certificateManager = certificateManager;
this.hostNames = parseHostNames();
this.certProvider = findCertProvider();
}
@Override
public void run() {
// Check for expiration
ExpiryState state = getExpiry(certificateManager.getSslKeyPair().getCert());
switch(state) {
case EXPIRING:
case EXPIRED:
log.info("Certificate ExpiryState {}, renewing/reloading...", state);
switch(certProvider) {
case INTERNAL:
if(renewInternalCert()) {
getExpiry();
}
break;
case CA_CERT_ORG:
case LETS_ENCRYPT:
if(renewExternalCert(certProvider)) {
getExpiry();
}
break;
case UNKNOWN:
default:
log.warn("Certificate can't be renewed/reloaded; ExpiryState: {}, CertProvider: {}", state, certProvider);
}
case VALID:
default:
}
}
public boolean renewInternalCert() {
try {
log.info("Requesting a new SSL certificate from {} ...", certificateManager.getCaKeyPair().getAlias());
certificateManager.renewCertChain(hostNames);
log.info("New SSL certificate created. Reloading SslContextFactory...");
certificateManager.reloadSslContextFactory();
log.info("Reloaded SSL successfully.");
return true;
}
catch(Exception e) {
log.error("Could not reload SSL certificate", e);
}
return false;
}
public ExpiryState getExpiry() {
return getExpiry(certificateManager.getSslKeyPair().getCert());
}
/**
* Returns true if the SSL certificate is generated by QZ Tray and expires inside the GRACE_PERIOD.
* GRACE_PERIOD is preferred for scheduling the renewals in advance, such as non-peak hours
*/
public static ExpiryState getExpiry(X509Certificate cert) {
// Invalid
if (cert == null) {
log.error("Can't check for expiration, certificate is missing.");
return ExpiryState.EXPIRED;
}
Date expireDate = cert.getNotAfter();
Calendar now = Calendar.getInstance(Locale.ENGLISH);
Calendar expires = Calendar.getInstance(Locale.ENGLISH);
expires.setTime(expireDate);
// Expired
if (now.after(expires)) {
log.info("SSL certificate has expired {}. It must be renewed immediately.", SystemUtilities.toISO(expireDate));
return ExpiryState.EXPIRED;
}
// Expiring
expires.add(Calendar.DAY_OF_YEAR, -DEFAULT_GRACE_PERIOD_DAYS);
if (now.after(expires)) {
log.info("SSL certificate will expire in less than {} days: {}", DEFAULT_GRACE_PERIOD_DAYS, SystemUtilities.toISO(expireDate));
return ExpiryState.EXPIRING;
}
// Valid
int days = (int)Math.round((expireDate.getTime() - new Date().getTime()) / (double)86400000);
log.info("SSL certificate is still valid for {} more days: {}. We'll make a new one automatically when needed.", days, SystemUtilities.toISO(expireDate));
return ExpiryState.VALID;
}
public void schedule() {
schedule(DEFAULT_INITIAL_DELAY, DEFAULT_CHECK_FREQUENCY);
}
public void schedule(int delayMillis, int freqMillis) {
if(timer != null) {
timer.cancel();
timer.purge();
}
timer = new Timer();
timer.scheduleAtFixedRate(this, delayMillis, freqMillis);
}
public String[] parseHostNames() {
return parseHostNames(certificateManager.getSslKeyPair().getCert());
}
public CertProvider findCertProvider() {
return findCertProvider(certificateManager.getSslKeyPair().getCert());
}
public static CertProvider findCertProvider(X509Certificate cert) {
// Internal certs use CN=localhost, trust email instead
if (CertificateManager.emailMatches(cert)) {
return CertProvider.INTERNAL;
}
String providerDN;
// check registered patterns to classify certificate
if(cert.getIssuerDN() != null && (providerDN = cert.getIssuerDN().getName()) != null) {
String cn = null;
try {
// parse issuer's DN
LdapName ldapName = new LdapName(providerDN);
for(Rdn rdn : ldapName.getRdns()) {
if(rdn.getType().equalsIgnoreCase("CN")) {
cn = (String)rdn.getValue();
break;
}
}
// compare cn to our pattern
if(cn != null) {
for(CertProvider provider : CertProvider.values()) {
for(String pattern : provider.patterns) {
if (cn.matches(pattern)) {
log.warn("Cert issuer detected as {}", provider.name());
return provider;
}
}
}
}
} catch(InvalidNameException ignore) {}
}
log.warn("A valid issuer couldn't be found, we won't know how to renew this cert when it expires");
return CertProvider.UNKNOWN;
}
public static String[] parseHostNames(X509Certificate cert) {
// Cache the SAN hosts for recreation
List<String> hostNameList = new ArrayList<>();
try {
Collection<List<?>> altNames = cert.getSubjectAlternativeNames();
if (altNames != null) {
for(List<?> altName : altNames) {
if(altName.size()< 1) continue;
switch((Integer)altName.get(0)) {
case GeneralName.dNSName:
case GeneralName.iPAddress:
Object data = altName.get(1);
if (data instanceof String) {
hostNameList.add(((String)data));
}
break;
default:
}
}
} else {
log.error("getSubjectAlternativeNames is null?");
}
log.debug("Parsed hostNames: {}", String.join(", ", hostNameList));
} catch(CertificateException e) {
log.warn("Can't parse hostNames from this cert. Cert renewals will contain default values instead");
}
return hostNameList.toArray(new String[hostNameList.size()]);
}
public boolean renewExternalCert(CertProvider externalProvider) {
switch(externalProvider) {
case LETS_ENCRYPT:
return renewLetsEncryptCert(externalProvider);
case CA_CERT_ORG:
default:
log.error("Cert renewal for {} is not implemented", externalProvider);
}
return false;
}
private boolean renewLetsEncryptCert(CertProvider externalProvider) {
try {
File storagePath = CertificateManager.getWritableLocation("ssl");
// cerbot is much simpler than acme, let's use it
Path root = Paths.get(SHARED_DIR.toString(), "letsencrypt", "config");
log.info("Attempting to renew {}. Assuming certs are installed in {}...", externalProvider, root);
List<String> cmds = new ArrayList(Arrays.asList("certbot", "--force-renewal", "certonly"));
cmds.add("--standalone");
cmds.add("--config-dir");
String config = Paths.get(SHARED_DIR.toString(), "ssl", "letsencrypt", "config").toString();
cmds.add(config);
cmds.add("--logs-dir");
cmds.add(Paths.get(SHARED_DIR.toString(), "ssl", "letsencrypt", "logs").toString());
cmds.add("--work-dir");
cmds.add(Paths.get(SHARED_DIR.toString(), "ssl", "letsencrypt").toString());
// append dns names
for(String hostName : hostNames) {
cmds.add("-d");
cmds.add(hostName);
}
if (ShellUtilities.execute(cmds.toArray(new String[cmds.size()]))) {
// Assume the cert is stored in a folder called "letsencrypt/config/live/<domain>"
Path keyPath = Paths.get(config, "live", hostNames[0], "privkey.pem");
Path certPath = Paths.get(config, "live", hostNames[0], "fullchain.pem"); // fullchain required
certificateManager.createTrustedKeystore(keyPath.toFile(), certPath.toFile());
log.info("Files imported, converted and saved. Reloading SslContextFactory...");
certificateManager.reloadSslContextFactory();
log.info("Reloaded SSL successfully.");
return true;
} else {
log.warn("Something went wrong renewing the LetsEncrypt certificate. Please run the certbot command manually to learn more.");
}
} catch(Exception e) {
log.error("Error renewing/reloading LetsEncrypt cert", e);
}
return false;
}
}

View File

@@ -0,0 +1,130 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate;
import qz.common.Constants;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.Enumeration;
/**
* Wrap handling of X509Certificate, PrivateKey and KeyStore conversion
*/
public class KeyPairWrapper {
public enum Type {CA, SSL}
private Type type;
private PrivateKey key;
private char[] password;
private X509Certificate cert;
private KeyStore keyStore; // for SSL
public KeyPairWrapper(Type type) {
this.type = type;
}
public KeyPairWrapper(Type type, KeyPair keyPair, X509Certificate cert) {
this.type = type;
this.key = keyPair.getPrivate();
this.cert = cert;
}
/**
* Load from disk
*/
public void init(File keyFile, char[] password) throws IOException, GeneralSecurityException {
KeyStore keyStore = KeyStore.getInstance(keyFile.getName().endsWith(".jks") ? "JKS" : "PKCS12");
keyStore.load(new FileInputStream(keyFile), password);
init(keyStore, password);
}
/**
* Load from memory
*/
public void init(KeyStore keyStore, char[] password) throws GeneralSecurityException {
this.keyStore = keyStore;
KeyStore.ProtectionParameter param = new KeyStore.PasswordProtection(password);
KeyStore.PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry)keyStore.getEntry(getAlias(), param);
// the entry we assume is always wrong for pkcs12 imports, search for it instead
if(entry == null) {
Enumeration<String> enumerator = keyStore.aliases();
while(enumerator.hasMoreElements()) {
String alias = enumerator.nextElement();
if(keyStore.isKeyEntry(alias)) {
this.password = password;
this.key = ((KeyStore.PrivateKeyEntry)keyStore.getEntry(alias, param)).getPrivateKey();
this.cert = (X509Certificate)keyStore.getCertificate(alias);
return;
}
}
throw new GeneralSecurityException("Could not initialize the KeyStore for internal use");
}
this.password = password;
this.key = entry.getPrivateKey();
this.cert = (X509Certificate)keyStore.getCertificate(getAlias());
}
public X509Certificate getCert() {
return cert;
}
public PrivateKey getKey() {
return key;
}
public String getPasswordString() {
return new String(password);
}
public char[] getPassword() {
return password;
}
public static String getAlias(Type type) {
switch(type) {
case SSL:
return Constants.PROPS_FILE; // "qz-tray"
case CA:
default:
return "root-ca";
}
}
public String getAlias() {
return getAlias(getType());
}
public String propsPrefix() {
switch(type) {
case SSL:
return "wss";
case CA:
default:
return "ca";
}
}
public Type getType() {
return type;
}
public KeyStore getKeyStore() {
return keyStore;
}
}

View File

@@ -0,0 +1,365 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate;
import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.SubjectKeyIdentifier;
import org.bouncycastle.util.encoders.Base64;
import qz.auth.X509Constants;
import qz.common.Constants;
import qz.installer.Installer;
import qz.utils.ByteUtilities;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import qz.utils.UnixUtilities;
import javax.swing.*;
import java.awt.*;
import java.io.*;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
import static qz.installer.Installer.PrivilegeLevel.*;
/**
* @author Tres Finocchiaro
*/
public class LinuxCertificateInstaller extends NativeCertificateInstaller {
private static final Logger log = LogManager.getLogger(LinuxCertificateInstaller.class);
private static final String CA_CERTIFICATES = "/usr/local/share/ca-certificates/";
private static final String CA_CERTIFICATE_NAME = Constants.PROPS_FILE + "-root.crt"; // e.g. qz-tray-root.crt
private static final String PK11_KIT_ID = "pkcs11:id=";
private static String[] NSSDB_URLS = {
// Conventional cert store
"sql:" + System.getenv("HOME") + "/.pki/nssdb/",
// Snap-specific cert stores
"sql:" + System.getenv("HOME") + "/snap/chromium/current/.pki/nssdb/",
"sql:" + System.getenv("HOME") + "/snap/brave/current/.pki/nssdb/",
"sql:" + System.getenv("HOME") + "/snap/opera/current/.pki/nssdb/",
"sql:" + System.getenv("HOME") + "/snap/opera-beta/current/.pki/nssdb/"
};
private Installer.PrivilegeLevel certType;
public LinuxCertificateInstaller(Installer.PrivilegeLevel certType) {
setInstallType(certType);
findCertutil();
}
public Installer.PrivilegeLevel getInstallType() {
return certType;
}
public void setInstallType(Installer.PrivilegeLevel certType) {
this.certType = certType;
if (this.certType == SYSTEM) {
log.warn("Command \"certutil\" (required for certain browsers) needs to run as USER. We'll try again on launch.");
}
}
public boolean remove(List<String> idList) {
boolean success = true;
if(certType == SYSTEM) {
boolean first = distrustUsingUpdateCaCertificates(idList);
boolean second = distrustUsingTrustAnchor(idList);
success = first || second;
} else {
for(String nickname : idList) {
for(String nssdb : NSSDB_URLS) {
success = success && ShellUtilities.execute("certutil", "-d", nssdb, "-D", "-n", nickname);
}
}
}
return success;
}
public List<String> find() {
ArrayList<String> nicknames = new ArrayList<>();
if(certType == SYSTEM) {
nicknames = findUsingTrustAnchor();
nicknames.addAll(findUsingUsingUpdateCaCert());
} else {
try {
for(String nssdb : NSSDB_URLS) {
Process p = Runtime.getRuntime().exec(new String[] {"certutil", "-d", nssdb, "-L"});
BufferedReader in = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line;
while((line = in.readLine()) != null) {
if (line.startsWith(Constants.ABOUT_COMPANY + " ")) {
nicknames.add(Constants.ABOUT_COMPANY);
break; // Stop reading input; nicknames can't appear more than once
}
}
in.close();
}
}
catch(IOException e) {
log.warn("Could not get certificate nicknames", e);
}
}
return nicknames;
}
public boolean verify(File ignore) { return true; } // no easy way to validate a cert, assume it's installed
public boolean add(File certFile) {
boolean success = true;
if(certType == SYSTEM) {
// Attempt two common methods for installing the SSL certificate
File systemCertFile;
boolean first = (systemCertFile = trustUsingUpdateCaCertificates(certFile)) != null;
boolean second = trustUsingTrustAnchor(systemCertFile, certFile);
success = first || second;
} else if(certType == USER) {
// Install certificate to local profile using "certutil"
for(String nssdb : NSSDB_URLS) {
String[] parts = nssdb.split(":", 2);
if (parts.length > 1) {
File folder = new File(parts[1]);
// If .pki/nssdb doesn't exist yet, don't create it! Per https://github.com/qzind/tray/issues/1003
if(folder.exists() && folder.isDirectory()) {
if (!ShellUtilities.execute("certutil", "-d", nssdb, "-A", "-t", "TC", "-n", Constants.ABOUT_COMPANY, "-i", certFile.getPath())) {
log.warn("Something went wrong creating {}. HTTPS will fail on certain browsers which depend on it.", nssdb);
success = false;
}
}
}
}
}
return success;
}
private boolean findCertutil() {
boolean installed = ShellUtilities.execute("which", "certutil");
if (!installed) {
if (certType == SYSTEM && promptCertutil()) {
if(UnixUtilities.isUbuntu() || UnixUtilities.isDebian()) {
installed = ShellUtilities.execute("apt-get", "install", "-y", "libnss3-tools");
} else if(UnixUtilities.isFedora()) {
installed = ShellUtilities.execute("dnf", "install", "-y", "nss-tools");
}
}
}
if(!installed) {
log.warn("A critical component, \"certutil\" wasn't found and cannot be installed automatically. HTTPS will fail on certain browsers which depend on it.");
}
return installed;
}
private boolean promptCertutil() {
// Assume silent or headless installs want certutil
if(Installer.IS_SILENT || GraphicsEnvironment.isHeadless()) {
return true;
}
try {
SystemUtilities.setSystemLookAndFeel(true);
return JOptionPane.YES_OPTION == JOptionPane.showConfirmDialog(null, "A critical component, \"certutil\" wasn't found. Attempt to fetch it now?");
} catch(Throwable ignore) {}
return true;
}
/**
* Common technique for installing system-wide certificates on Debian-based systems (Ubuntu, etc.)
*
* This technique is only known to work for select browsers, such as Epiphany. Browsers such as
* Firefox and Chromium require different techniques.
*
* @return Full path to the destination file if successful, otherwise <code>null</code>
*/
private File trustUsingUpdateCaCertificates(File certFile) {
if(hasUpdateCaCertificatesCommand()) {
File destFile = new File(CA_CERTIFICATES, CA_CERTIFICATE_NAME);
log.debug("Copying SYSTEM SSL certificate {} to {}", certFile.getPath(), destFile.getPath());
try {
if (new File(CA_CERTIFICATES).isDirectory()) {
// Note: preserveFileDate=false per https://github.com/qzind/tray/issues/1011
FileUtils.copyFile(certFile, destFile, false);
if (destFile.isFile()) {
// Attempt "update-ca-certificates" (Debian)
if (!ShellUtilities.execute("update-ca-certificates")) {
log.warn("Something went wrong calling \"update-ca-certificates\" for the SYSTEM SSL certificate.");
} else {
return destFile;
}
}
} else {
log.warn("{} is not a valid directory, skipping", CA_CERTIFICATES);
}
}
catch(IOException e) {
log.warn("Error copying SYSTEM SSL certificate file", e);
}
} else {
log.warn("Skipping SYSTEM SSL certificate install using \"update-ca-certificates\", command missing or invalid");
}
return null;
}
/**
* Common technique for installing system-wide certificates on Fedora-based systems
*
* Uses first existing non-null file provided
*/
private boolean trustUsingTrustAnchor(File ... certFiles) {
if (hasTrustAnchorCommand()) {
for(File certFile : certFiles) {
if (certFile == null || !certFile.exists()) {
continue;
}
// Install certificate to system using "trust anchor" (Fedora)
if (ShellUtilities.execute("trust", "anchor", "--store", certFile.getPath())) {
return true;
} else {
log.warn("Something went wrong calling \"trust anchor\" for the SYSTEM SSL certificate.");
}
}
} else {
log.warn("Skipping SYSTEM SSL certificate install using \"trust anchor\", command missing or invalid");
}
return false;
}
private boolean distrustUsingUpdateCaCertificates(List<String> paths) {
if(hasUpdateCaCertificatesCommand()) {
boolean deleted = false;
for(String path : paths) {
// Process files only; not "trust anchor" URIs
if(!path.startsWith(PK11_KIT_ID)) {
File certFile = new File(path);
if (certFile.isFile() && certFile.delete()) {
deleted = true;
} else {
log.warn("SYSTEM SSL certificate {} does not exist, skipping", certFile.getPath());
}
}
}
// Attempt "update-ca-certificates" (Debian)
if(deleted) {
if (ShellUtilities.execute("update-ca-certificates")) {
return true;
} else {
log.warn("Something went wrong calling \"update-ca-certificates\" for the SYSTEM SSL certificate.");
}
}
} else {
log.warn("Skipping SYSTEM SSL certificate removal using \"update-ca-certificates\", command missing or invalid");
}
return false;
}
private boolean distrustUsingTrustAnchor(List<String> idList) {
if(hasTrustAnchorCommand()) {
for(String id : idList) {
// only remove by id
if (id.startsWith(PK11_KIT_ID) && !ShellUtilities.execute("trust", "anchor", "--remove", id)) {
log.warn("Something went wrong calling \"trust anchor\" for the SYSTEM SSL certificate.");
}
}
} else {
log.warn("Skipping SYSTEM SSL certificate removal using \"trust anchor\", command missing or invalid");
}
return false;
}
/**
* Check for the presence of a QZ certificate in known locations (e.g. /usr/local/share/ca-certificates/
* and return the path if found
*/
private ArrayList<String> findUsingUsingUpdateCaCert() {
ArrayList<String> found = new ArrayList<>();
File[] systemCertFiles = { new File(CA_CERTIFICATES, CA_CERTIFICATE_NAME) };
for(File file : systemCertFiles) {
if(file.isFile()) {
found.add(file.getPath());
}
}
return found;
}
/**
* Find QZ installed certificates in the "trust anchor" by searching by email.
*
* The "trust" utility identifies certificates as URIs:
* Example:
* pkcs11:id=%7C%5D%02%84%13%D4%CC%8A%9B%81%CE%17%1C%2E%29%1E%9C%48%63%42;type=cert
* ... which is an encoded version of the cert's SubjectKeyIdentifier field
* To identify a match:
* 1. Extract all trusted certificates and look for a familiar email address
* 2. If found, construct and store a "trust" compatible URI as the nickname
*/
private ArrayList<String> findUsingTrustAnchor() {
ArrayList<String> uris = new ArrayList<>();
File tempFile = null;
try {
// Temporary location for system certificates
tempFile = File.createTempFile("trust-extract-for-qz-", ".pem");
// Delete before use: "trust extract" requires an empty file
tempFile.delete();
if(ShellUtilities.execute("trust", "extract", "--format", "pem-bundle", tempFile.getPath())) {
BufferedReader reader = new BufferedReader(new FileReader(tempFile));
String line;
StringBuilder base64 = new StringBuilder();
while ((line = reader.readLine()) != null) {
if(line.startsWith(X509Constants.BEGIN_CERT)) {
// Beginning of a new certificate
base64.setLength(0);
} else if(line.startsWith(X509Constants.END_CERT)) {
// End of the existing certificate
byte[] certBytes = Base64.decode(base64.toString());
CertificateFactory factory = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate)factory.generateCertificate(new ByteArrayInputStream(certBytes));
if(CertificateManager.emailMatches(cert, true)) {
byte[] extensionValue = cert.getExtensionValue(Extension.subjectKeyIdentifier.getId());
byte[] octets = DEROctetString.getInstance(extensionValue).getOctets();
SubjectKeyIdentifier subjectKeyIdentifier = SubjectKeyIdentifier.getInstance(octets);
byte[] keyIdentifier = subjectKeyIdentifier.getKeyIdentifier();
String hex = ByteUtilities.bytesToHex(keyIdentifier, true);
String uri = PK11_KIT_ID + hex.replaceAll("(.{2})", "%$1") + ";type=cert";
log.info("Found matching cert: {}", uri);
uris.add(uri);
}
} else {
base64.append(line);
}
}
reader.close();
}
} catch(IOException | CertificateException e) {
log.warn("An error occurred finding preexisting \"trust anchor\" certificates", e);
} finally {
if(tempFile != null && !tempFile.delete()) {
tempFile.deleteOnExit();
}
}
return uris;
}
private boolean hasUpdateCaCertificatesCommand() {
return ShellUtilities.execute("which", "update-ca-certificates");
}
private boolean hasTrustAnchorCommand() {
return ShellUtilities.execute("trust", "anchor", "--help");
}
}

View File

@@ -0,0 +1,91 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.installer.Installer;
import qz.utils.ShellUtilities;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
public class MacCertificateInstaller extends NativeCertificateInstaller {
private static final Logger log = LogManager.getLogger(MacCertificateInstaller.class);
public static final String USER_STORE = System.getProperty("user.home") + "/Library/Keychains/login.keychain"; // aka login.keychain-db
public static final String SYSTEM_STORE = "/Library/Keychains/System.keychain";
private String certStore;
public MacCertificateInstaller(Installer.PrivilegeLevel certType) {
setInstallType(certType);
}
public boolean add(File certFile) {
if (certStore.equals(USER_STORE)) {
// This will prompt the user
return ShellUtilities.execute("security", "add-trusted-cert", "-r", "trustRoot", "-k", certStore, certFile.getPath());
} else {
return ShellUtilities.execute("security", "add-trusted-cert", "-d", "-r", "trustRoot", "-k", certStore, certFile.getPath());
}
}
public boolean remove(List<String> idList) {
boolean success = true;
for (String certId : idList) {
success = success && ShellUtilities.execute("security", "delete-certificate", "-Z", certId, certStore);
}
return success;
}
public List<String> find() {
ArrayList<String> hashList = new ArrayList<>();
try {
Process p = Runtime.getRuntime().exec(new String[] {"security", "find-certificate", "-a", "-e", Constants.ABOUT_EMAIL, "-Z", certStore});
BufferedReader in = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line;
while ((line = in.readLine()) != null) {
if (line.contains("SHA-1") && line.contains(":")) {
hashList.add(line.split(":", 2)[1].trim());
}
}
in.close();
} catch(IOException e) {
log.warn("Could not get certificate list", e);
}
return hashList;
}
public boolean verify(File certFile) {
return ShellUtilities.execute( "security", "verify-cert", "-c", certFile.getPath());
}
public void setInstallType(Installer.PrivilegeLevel type) {
if (type == Installer.PrivilegeLevel.USER) {
certStore = USER_STORE;
} else {
certStore = SYSTEM_STORE;
}
}
public Installer.PrivilegeLevel getInstallType() {
if (certStore == USER_STORE) {
return Installer.PrivilegeLevel.USER;
} else {
return Installer.PrivilegeLevel.SYSTEM;
}
}
}

View File

@@ -0,0 +1,105 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate;
import org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.installer.Installer;
import qz.utils.SystemUtilities;
import java.io.File;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.security.cert.X509Certificate;
import java.util.List;
public abstract class NativeCertificateInstaller {
private static final Logger log = LogManager.getLogger(NativeCertificateInstaller.class);
protected static NativeCertificateInstaller instance;
public static NativeCertificateInstaller getInstance() {
return getInstance(SystemUtilities.isAdmin() ? Installer.PrivilegeLevel.SYSTEM : Installer.PrivilegeLevel.USER);
}
public static NativeCertificateInstaller getInstance(Installer.PrivilegeLevel type) {
if (instance == null) {
switch(SystemUtilities.getOs()) {
case WINDOWS:
instance = new WindowsCertificateInstaller(type);
break;
case MAC:
instance = new MacCertificateInstaller(type);
break;
case LINUX:
default:
instance = new LinuxCertificateInstaller(type);
}
}
return instance;
}
/**
* Install a certificate from memory
*/
public boolean install(X509Certificate cert) {
File certFile = null;
try {
certFile = File.createTempFile(KeyPairWrapper.getAlias(KeyPairWrapper.Type.CA) + "-", CertificateManager.DEFAULT_CERTIFICATE_EXTENSION);
JcaMiscPEMGenerator generator = new JcaMiscPEMGenerator(cert);
JcaPEMWriter writer = new JcaPEMWriter(new OutputStreamWriter(Files.newOutputStream(certFile.toPath(), StandardOpenOption.CREATE)));
writer.writeObject(generator.generate());
writer.close();
return install(certFile);
} catch(IOException e) {
log.warn("Could not install cert from temp file", e);
} finally {
if(certFile != null && !certFile.delete()) {
certFile.deleteOnExit();
}
}
return false;
}
/**
* Install a certificate from disk
*/
public boolean install(File certFile) {
String helper = instance.getClass().getSimpleName();
String store = instance.getInstallType().name();
if(SystemUtilities.isJar()) {
if (remove(find())) {
log.info("Certificate removed from {} store using {}", store, helper);
} else {
log.warn("Could not remove certificate from {} store using {}", store, helper);
}
} else {
log.info("Skipping {} store certificate removal, IDE detected.", store, helper);
}
if (add(certFile)) {
log.info("Certificate added to {} store using {}", store, helper);
return true;
} else {
log.warn("Could not install certificate to {} store using {}", store, helper);
}
return false;
}
public abstract boolean add(File certFile);
public abstract boolean remove(List<String> idList);
public abstract List<String> find();
public abstract boolean verify(File certFile);
public abstract void setInstallType(Installer.PrivilegeLevel certType);
public abstract Installer.PrivilegeLevel getInstallType();
}

View File

@@ -0,0 +1,236 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate;
import com.sun.jna.Memory;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.Structure;
import com.sun.jna.platform.win32.Kernel32Util;
import com.sun.jna.platform.win32.WinNT;
import com.sun.jna.win32.StdCallLibrary;
import com.sun.jna.win32.W32APIOptions;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.openssl.PEMParser;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.installer.Installer;
import java.io.*;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
public class WindowsCertificateInstaller extends NativeCertificateInstaller {
private static final Logger log = LogManager.getLogger(WindowsCertificateInstaller.class);
private WinCrypt.HCERTSTORE store;
private byte[] certBytes;
private Installer.PrivilegeLevel certType;
public WindowsCertificateInstaller(Installer.PrivilegeLevel certType) {
setInstallType(certType);
}
public boolean add(File certFile) {
log.info("Writing certificate {} to {} store using Crypt32...", certFile, certType);
try {
byte[] bytes = getCertBytes(certFile);
Pointer pointer = new Memory(bytes.length);
pointer.write(0, bytes, 0, bytes.length);
boolean success = Crypt32.INSTANCE.CertAddEncodedCertificateToStore(
openStore(),
WinCrypt.X509_ASN_ENCODING,
pointer,
bytes.length,
Crypt32.CERT_STORE_ADD_REPLACE_EXISTING,
null
);
if(!success) {
log.warn(Kernel32Util.formatMessage(Native.getLastError()));
}
closeStore();
return success;
} catch(IOException e) {
log.warn("An error occurred installing the certificate", e);
} finally {
certBytes = null;
}
return false;
}
private byte[] getCertBytes(File certFile) throws IOException {
if(certBytes == null) {
PEMParser pem = new PEMParser(new FileReader(certFile));
X509CertificateHolder certHolder = (X509CertificateHolder)pem.readObject();
certBytes = certHolder.getEncoded();
}
return certBytes;
}
private WinCrypt.HCERTSTORE openStore() {
if(store == null) {
store = openStore(certType);
}
return store;
}
private void closeStore() {
if(store != null && closeStore(store)) {
store = null;
} else {
log.warn("Unable to close {} cert store", certType);
}
}
private static WinCrypt.HCERTSTORE openStore(Installer.PrivilegeLevel certType) {
log.info("Opening {} store using Crypt32...", certType);
WinCrypt.HCERTSTORE store = Crypt32.INSTANCE.CertOpenStore(
Crypt32.CERT_STORE_PROV_SYSTEM,
0,
null,
certType == Installer.PrivilegeLevel.USER ? Crypt32.CERT_SYSTEM_STORE_CURRENT_USER : Crypt32.CERT_SYSTEM_STORE_LOCAL_MACHINE,
"ROOT"
);
if(store == null) {
log.warn(Kernel32Util.formatMessage(Native.getLastError()));
}
return store;
}
private static boolean closeStore(WinCrypt.HCERTSTORE certStore) {
boolean isClosed = Crypt32.INSTANCE.CertCloseStore(
certStore, 0
);
if(!isClosed) {
log.warn(Kernel32Util.formatMessage(Native.getLastError()));
}
return isClosed;
}
public boolean remove(List<String> ignore) {
boolean success = true;
WinCrypt.CERT_CONTEXT hCertContext;
WinCrypt.CERT_CONTEXT pPrevCertContext = null;
while(true) {
hCertContext = Crypt32.INSTANCE.CertFindCertificateInStore(
openStore(),
WinCrypt.X509_ASN_ENCODING,
0,
Crypt32.CERT_FIND_SUBJECT_STR,
Constants.ABOUT_EMAIL,
pPrevCertContext);
if(hCertContext == null) {
break;
}
pPrevCertContext = Crypt32.INSTANCE.CertDuplicateCertificateContext(hCertContext);
if(success = (success && Crypt32.INSTANCE.CertDeleteCertificateFromStore(hCertContext))) {
log.info("Successfully deleted certificate matching {}", Constants.ABOUT_EMAIL);
} else {
log.info("Could not delete certificate: {}", Kernel32Util.formatMessage(Native.getLastError()));
}
}
closeStore();
return success;
}
public List<String> find() {
return null;
}
public void setInstallType(Installer.PrivilegeLevel type) {
this.certType = type;
}
public Installer.PrivilegeLevel getInstallType() {
return certType;
}
public boolean verify(File certFile) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(getCertBytes(certFile));
WinCrypt.DATA_BLOB thumbPrint = new WinCrypt.DATA_BLOB(md.digest());
WinNT.HANDLE cert = Crypt32.INSTANCE.CertFindCertificateInStore(
openStore(),
WinCrypt.X509_ASN_ENCODING,
0,
Crypt32.CERT_FIND_SHA1_HASH,
thumbPrint,
null);
return cert != null;
} catch(IOException | NoSuchAlgorithmException e) {
log.warn("An error occurred verifying the cert is installed: {}", certFile, e);
}
return false;
}
/**
* The JNA's Crypt32 instance oversimplifies store handling, preventing user stores from being used
*/
interface Crypt32 extends StdCallLibrary {
int CERT_SYSTEM_STORE_CURRENT_USER = 65536;
int CERT_SYSTEM_STORE_LOCAL_MACHINE = 131072;
int CERT_STORE_PROV_SYSTEM = 10;
int CERT_STORE_ADD_REPLACE_EXISTING = 3;
int CERT_FIND_SUBJECT_STR = 524295;
int CERT_FIND_SHA1_HASH = 65536;
Crypt32 INSTANCE = Native.load("Crypt32", Crypt32.class, W32APIOptions.DEFAULT_OPTIONS);
WinCrypt.HCERTSTORE CertOpenStore(int lpszStoreProvider, int dwMsgAndCertEncodingType, Pointer hCryptProv, int dwFlags, String pvPara);
boolean CertCloseStore(WinCrypt.HCERTSTORE hCertStore, int dwFlags);
boolean CertAddEncodedCertificateToStore(WinCrypt.HCERTSTORE hCertStore, int dwCertEncodingType, Pointer pbCertEncoded, int cbCertEncoded, int dwAddDisposition, Pointer ppCertContext);
WinCrypt.CERT_CONTEXT CertFindCertificateInStore (WinCrypt.HCERTSTORE hCertStore, int dwCertEncodingType, int dwFindFlags, int dwFindType, String pvFindPara, WinCrypt.CERT_CONTEXT pPrevCertContext);
WinCrypt.CERT_CONTEXT CertFindCertificateInStore (WinCrypt.HCERTSTORE hCertStore, int dwCertEncodingType, int dwFindFlags, int dwFindType, Structure pvFindPara, WinCrypt.CERT_CONTEXT pPrevCertContext);
boolean CertDeleteCertificateFromStore(WinCrypt.CERT_CONTEXT pCertContext);
boolean CertFreeCertificateContext(WinCrypt.CERT_CONTEXT pCertContext);
WinCrypt.CERT_CONTEXT CertDuplicateCertificateContext(WinCrypt.CERT_CONTEXT pCertContext);
}
// Polyfill from JNA5+
@SuppressWarnings("UnusedDeclaration") //Library class
public static class WinCrypt {
public static int X509_ASN_ENCODING = 0x00000001;
public static class HCERTSTORE extends WinNT.HANDLE {
public HCERTSTORE() {}
public HCERTSTORE(Pointer p) {
super(p);
}
}
public static class CERT_CONTEXT extends WinNT.HANDLE {
public CERT_CONTEXT() {}
public CERT_CONTEXT(Pointer p) {
super(p);
}
}
public static class DATA_BLOB extends com.sun.jna.platform.win32.WinCrypt.DATA_BLOB {
// Wrap the constructor for code readability
public DATA_BLOB() {
super();
}
public DATA_BLOB(byte[] data) {
super(data);
}
}
}
}

View File

@@ -0,0 +1,136 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.installer.Installer;
import qz.utils.ShellUtilities;
import qz.utils.WindowsUtilities;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
/**
* Command Line technique for installing certificates on Windows
* Fallback class for when JNA is not available (e.g. Windows on ARM)
*/
@SuppressWarnings("UnusedDeclaration") //Library class
public class WindowsCertificateInstallerCli extends NativeCertificateInstaller {
private static final Logger log = LogManager.getLogger(WindowsCertificateInstallerCli.class);
private Installer.PrivilegeLevel certType;
public WindowsCertificateInstallerCli(Installer.PrivilegeLevel certType) {
setInstallType(certType);
}
public boolean add(File certFile) {
if (WindowsUtilities.isWindowsXP()) return false;
if (certType == Installer.PrivilegeLevel.USER) {
// This will prompt the user
return ShellUtilities.execute("certutil.exe", "-addstore", "-f", "-user", "Root", certFile.getPath());
} else {
return ShellUtilities.execute("certutil.exe", "-addstore", "-f", "Root", certFile.getPath());
}
}
public boolean remove(List<String> idList) {
if (WindowsUtilities.isWindowsXP()) return false;
boolean success = true;
for (String certId : idList) {
if (certType == Installer.PrivilegeLevel.USER) {
success = success && ShellUtilities.execute("certutil.exe", "-delstore", "-user", "Root", certId);
} else {
success = success && ShellUtilities.execute("certutil.exe", "-delstore", "Root", certId);
}
}
return success;
}
/**
* Returns a list of serials, if found
*/
public List<String> find() {
ArrayList<String> serialList = new ArrayList<>();
try {
Process p;
if (certType == Installer.PrivilegeLevel.USER) {
p = Runtime.getRuntime().exec(new String[] {"certutil.exe", "-store", "-user", "Root"});
} else {
p = Runtime.getRuntime().exec(new String[] {"certutil.exe", "-store", "Root"});
}
BufferedReader in = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line;
while ((line = in.readLine()) != null) {
if (line.contains("================")) {
// First line is serial
String serial = parseNextLine(in);
if (serial != null) {
// Second line is issuer
String issuer = parseNextLine(in);
if (issuer.contains("OU=" + Constants.ABOUT_COMPANY)) {
serialList.add(serial);
}
}
}
}
in.close();
} catch(Exception e) {
log.info("Unable to find a Trusted Root Certificate matching \"OU={}\"", Constants.ABOUT_COMPANY);
}
return serialList;
}
public boolean verify(File certFile) {
return verifyCert(certFile);
}
public static boolean verifyCert(File certFile) {
// -user also will check the root store
String dwErrorStatus = ShellUtilities.execute( new String[] {"certutil", "-user", "-verify", certFile.getPath() }, new String[] { "dwErrorStatus=" }, false, false);
if(!dwErrorStatus.isEmpty()) {
String[] parts = dwErrorStatus.split("[\r\n\\s]+");
for(String part : parts) {
if(part.startsWith("dwErrorStatus=")) {
log.info("Certificate validity says {}", part);
String[] status = part.split("=", 2);
if (status.length == 2) {
return status[1].trim().equals("0");
}
}
}
}
log.warn("Unable to determine certificate validity, you'll be prompted on startup");
return false;
}
public void setInstallType(Installer.PrivilegeLevel type) {
this.certType = type;
}
public Installer.PrivilegeLevel getInstallType() {
return certType;
}
private static String parseNextLine(BufferedReader reader) throws IOException {
String data = reader.readLine();
if (data != null) {
String[] split = data.split(":", 2);
if (split.length == 2) {
return split[1].trim();
}
}
return null;
}
}

View File

@@ -0,0 +1,7 @@
package qz.installer.certificate.firefox;
class ConflictingPolicyException extends Exception {
ConflictingPolicyException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,282 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate.firefox;
import com.github.zafarkhaja.semver.Version;
import com.sun.jna.platform.win32.WinReg;
import org.codehaus.jettison.json.JSONException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.installer.Installer;
import qz.installer.certificate.CertificateManager;
import qz.installer.certificate.firefox.locator.AppAlias;
import qz.installer.certificate.firefox.locator.AppInfo;
import qz.installer.certificate.firefox.locator.AppLocator;
import qz.utils.JsonWriter;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import qz.utils.WindowsUtilities;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Base64;
/**
* Installs the Firefox Policy file via Enterprise Policy, Distribution Policy file or AutoConfig, depending on OS & version
*/
public class FirefoxCertificateInstaller {
protected static final Logger log = LogManager.getLogger(FirefoxCertificateInstaller.class);
/**
* Versions are for Mozilla's official Firefox release.
* 3rd-party/clones may adopt Enterprise Policy support under
* different version numbers, adapt as needed.
*/
private static final Version WINDOWS_POLICY_VERSION = Version.valueOf("62.0.0");
private static final Version MAC_POLICY_VERSION = Version.valueOf("63.0.0");
private static final Version LINUX_POLICY_VERSION = Version.valueOf("65.0.0");
public static final Version FIREFOX_RESTART_VERSION = Version.valueOf("60.0.0");
public static final String LINUX_GLOBAL_POLICY_LOCATION = "/etc/firefox/policies/policies.json";
public static final String LINUX_SNAP_CERT_LOCATION = "/etc/firefox/policies/" + Constants.PROPS_FILE + CertificateManager.DEFAULT_CERTIFICATE_EXTENSION; // See https://github.com/mozilla/policy-templates/issues/936
public static final String LINUX_GLOBAL_CERT_LOCATION = "/usr/lib/mozilla/certificates/" + Constants.PROPS_FILE + CertificateManager.DEFAULT_CERTIFICATE_EXTENSION;
private static String DISTRIBUTION_ENTERPRISE_ROOT_POLICY = "{ \"policies\": { \"Certificates\": { \"ImportEnterpriseRoots\": true } } }";
private static String DISTRIBUTION_INSTALL_CERT_POLICY = "{ \"policies\": { \"Certificates\": { \"Install\": [ \"" + Constants.PROPS_FILE + CertificateManager.DEFAULT_CERTIFICATE_EXTENSION + "\", \"" + LINUX_SNAP_CERT_LOCATION + "\" ] } } }";
private static String DISTRIBUTION_REMOVE_CERT_POLICY = "{ \"policies\": { \"Certificates\": { \"Install\": [ \"/opt/" + Constants.PROPS_FILE + "/auth/root-ca.crt\"] } } }";
public static final String DISTRIBUTION_POLICY_LOCATION = "distribution/policies.json";
public static final String DISTRIBUTION_MAC_POLICY_LOCATION = "Contents/Resources/" + DISTRIBUTION_POLICY_LOCATION;
public static final String POLICY_AUDIT_MESSAGE = "Enterprise policy installed by " + Constants.ABOUT_TITLE + " on " + SystemUtilities.timeStamp();
public static void install(X509Certificate cert, String ... hostNames) {
// Blindly install Firefox enterprise policies to the system (macOS, Windows)
ArrayList<AppAlias.Alias> enterpriseFailed = new ArrayList<>();
for(AppAlias.Alias alias : AppAlias.FIREFOX.getAliases()) {
boolean success = false;
try {
if(alias.isEnterpriseReady() && !hasEnterprisePolicy(alias, false)) {
log.info("Installing Firefox enterprise certificate policy for {}", alias);
success = installEnterprisePolicy(alias, false);
}
} catch(ConflictingPolicyException e) {
log.warn("Conflict found installing {} enterprise cert support. We'll fallback on the distribution policy instead", alias.getName(), e);
}
if(!success) {
enterpriseFailed.add(alias);
}
}
// Search for installed instances
ArrayList<AppInfo> foundApps = AppLocator.getInstance().locate(AppAlias.FIREFOX);
ArrayList<Path> processPaths = null;
for(AppInfo appInfo : foundApps) {
boolean success = false;
if (honorsPolicy(appInfo)) {
if((SystemUtilities.isWindows()|| SystemUtilities.isMac()) && !enterpriseFailed.contains(appInfo.getAlias())) {
// Enterprise policy was already installed
success = true;
} else {
log.info("Installing Firefox distribution policy for {}", appInfo);
success = installDistributionPolicy(appInfo, cert);
}
} else {
log.info("Installing Firefox auto-config script for {}", appInfo);
try {
String certData = Base64.getEncoder().encodeToString(cert.getEncoded());
success = LegacyFirefoxCertificateInstaller.installAutoConfigScript(appInfo, certData, hostNames);
}
catch(CertificateEncodingException e) {
log.warn("Unable to install auto-config script for {}", appInfo, e);
}
}
if(success) {
issueRestartWarning(processPaths = AppLocator.getRunningPaths(foundApps, processPaths), appInfo);
}
}
}
public static void uninstall() {
ArrayList<AppInfo> appList = AppLocator.getInstance().locate(AppAlias.FIREFOX);
for(AppInfo appInfo : appList) {
if(honorsPolicy(appInfo)) {
if(SystemUtilities.isWindows() || SystemUtilities.isMac()) {
log.info("Skipping uninstall of Firefox enterprise root certificate policy for {}", appInfo);
} else {
try {
File policy = appInfo.getPath().resolve(DISTRIBUTION_POLICY_LOCATION).toFile();
if(policy.exists()) {
JsonWriter.write(appInfo.getPath().resolve(DISTRIBUTION_POLICY_LOCATION).toString(), DISTRIBUTION_INSTALL_CERT_POLICY, false, true);
}
} catch(IOException | JSONException e) {
log.warn("Unable to remove Firefox policy for {}", appInfo, e);
}
}
} else {
log.info("Uninstalling Firefox auto-config script for {}", appInfo);
LegacyFirefoxCertificateInstaller.uninstallAutoConfigScript(appInfo);
}
}
}
public static boolean honorsPolicy(AppInfo appInfo) {
if (appInfo.getVersion() == null) {
log.warn("Firefox-compatible browser found {}, but no version information is available", appInfo);
return false;
}
if(SystemUtilities.isWindows()) {
return appInfo.getVersion().greaterThanOrEqualTo(WINDOWS_POLICY_VERSION);
} else if (SystemUtilities.isMac()) {
return appInfo.getVersion().greaterThanOrEqualTo(MAC_POLICY_VERSION);
} else {
return appInfo.getVersion().greaterThanOrEqualTo(LINUX_POLICY_VERSION);
}
}
/**
* Returns true if an alternative Firefox policy (e.g. registry, plist user or system) is installed
*/
private static boolean hasEnterprisePolicy(AppAlias.Alias alias, boolean userOnly) throws ConflictingPolicyException {
if(SystemUtilities.isWindows()) {
String key = String.format("Software\\Policies\\%s\\%s\\Certificates", alias.getVendor(), alias.getName(true));
Integer foundPolicy = WindowsUtilities.getRegInt(userOnly ? WinReg.HKEY_CURRENT_USER : WinReg.HKEY_LOCAL_MACHINE, key, "ImportEnterpriseRoots");
if(foundPolicy != null) {
return foundPolicy == 1;
}
} else if(SystemUtilities.isMac()) {
String policyLocation = "/Library/Preferences/";
if(userOnly) {
policyLocation = System.getProperty("user.home") + policyLocation;
}
String policesEnabled = ShellUtilities.executeRaw(new String[] { "defaults", "read", policyLocation + alias.getBundleId(), "EnterprisePoliciesEnabled"}, true);
String foundPolicy = ShellUtilities.executeRaw(new String[] {"defaults", "read", policyLocation + alias.getBundleId(), "Certificates"}, true);
if(!policesEnabled.isEmpty() && !foundPolicy.isEmpty()) {
// Policies exist, decide how to proceed
if(policesEnabled.trim().equals("1") && foundPolicy.contains("ImportEnterpriseRoots = 1;")) {
return true;
}
throw new ConflictingPolicyException(String.format("%s enterprise policy conflict at %s: %s", alias.getName(), policyLocation + alias.getBundleId(), foundPolicy));
}
} else {
// Linux alternate policy not yet supported
}
return false;
}
/**
* Install policy to distribution/policies.json
*/
public static boolean installDistributionPolicy(AppInfo app, X509Certificate cert) {
Path jsonPath = app.getPath().resolve(SystemUtilities.isMac() ? DISTRIBUTION_MAC_POLICY_LOCATION:DISTRIBUTION_POLICY_LOCATION);
String jsonPolicy = SystemUtilities.isWindows() || SystemUtilities.isMac() ? DISTRIBUTION_ENTERPRISE_ROOT_POLICY:DISTRIBUTION_INSTALL_CERT_POLICY;
// Special handling for snaps
if(app.getPath().toString().startsWith("/snap")) {
log.info("Snap detected, installing policy file to global location instead: {}", LINUX_GLOBAL_POLICY_LOCATION);
jsonPath = Paths.get(LINUX_GLOBAL_POLICY_LOCATION);
}
try {
if(jsonPolicy.equals(DISTRIBUTION_INSTALL_CERT_POLICY)) {
// Linux lacks the concept of "enterprise roots", we'll write it to a known location instead
writeCertFile(cert, LINUX_SNAP_CERT_LOCATION); // so that the snap can read from it
writeCertFile(cert, LINUX_GLOBAL_CERT_LOCATION); // default location for non-snaps
}
File jsonFile = jsonPath.toFile();
// Make sure we can traverse and read
File distribution = jsonFile.getParentFile();
distribution.mkdirs();
distribution.setReadable(true, false);
distribution.setExecutable(true, false);
if(jsonPolicy.equals(DISTRIBUTION_INSTALL_CERT_POLICY)) {
// Delete previous policy
JsonWriter.write(jsonPath.toString(), DISTRIBUTION_REMOVE_CERT_POLICY, false, true);
}
JsonWriter.write(jsonPath.toString(), jsonPolicy, false, false);
// Make sure ew can read
jsonFile.setReadable(true, false);
return true;
} catch(JSONException | IOException e) {
log.warn("Could not install distribution policy {} to {}", jsonPolicy, jsonPath.toString(), e);
}
return false;
}
public static boolean installEnterprisePolicy(AppAlias.Alias alias, boolean userOnly) {
if(SystemUtilities.isWindows()) {
String key = String.format("Software\\Policies\\%s\\%s\\Certificates", alias.getVendor(), alias.getName(true));;
WindowsUtilities.addRegValue(userOnly ? WinReg.HKEY_CURRENT_USER : WinReg.HKEY_LOCAL_MACHINE, key, "Comment", POLICY_AUDIT_MESSAGE);
return WindowsUtilities.addRegValue(userOnly ? WinReg.HKEY_CURRENT_USER : WinReg.HKEY_LOCAL_MACHINE, key, "ImportEnterpriseRoots", 1);
} else if(SystemUtilities.isMac()) {
String policyLocation = "/Library/Preferences/";
if(userOnly) {
policyLocation = System.getProperty("user.home") + policyLocation;
}
return ShellUtilities.execute(new String[] {"defaults", "write", policyLocation + alias.getBundleId(), "EnterprisePoliciesEnabled", "-bool", "TRUE"}, true) &&
ShellUtilities.execute(new String[] {"defaults", "write", policyLocation + alias.getBundleId(), "Certificates", "-dict", "ImportEnterpriseRoots", "-bool", "TRUE",
"Comment", "-string", POLICY_AUDIT_MESSAGE}, true);
}
return false;
}
public static boolean issueRestartWarning(ArrayList<Path> runningPaths, AppInfo appInfo) {
boolean firefoxIsRunning = runningPaths.contains(appInfo.getExePath());
// Edge case for detecting if snap is running, since we can't compare the exact path easily
for(Path runningPath : runningPaths) {
if(runningPath.startsWith("/snap/")) {
firefoxIsRunning = true;
}
}
if (firefoxIsRunning) {
if (appInfo.getVersion().greaterThanOrEqualTo(FirefoxCertificateInstaller.FIREFOX_RESTART_VERSION)) {
try {
Installer.getInstance().spawn(appInfo.getExePath().toString(), "-private", "about:restartrequired");
return true;
}
catch(Exception ignore) {}
} else {
log.warn("{} must be restarted manually for changes to take effect", appInfo);
}
}
return false;
}
private static void writeCertFile(X509Certificate cert, String location) throws IOException {
File certFile = new File(location);
// Make sure we can traverse and read
File certs = new File(location).getParentFile();
certs.mkdirs();
certs.setReadable(true, false);
certs.setExecutable(true, false);
File mozilla = certs.getParentFile();
mozilla.setReadable(true, false);
mozilla.setExecutable(true, false);
// Make sure we can read
CertificateManager.writeCert(cert, certFile);
certFile.setReadable(true, false);
}
}

View File

@@ -0,0 +1,150 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate.firefox;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.installer.certificate.CertificateChainBuilder;
import qz.installer.certificate.firefox.locator.AppInfo;
import qz.utils.FileUtilities;
import qz.utils.SystemUtilities;
import java.io.*;
import java.nio.file.Path;
import java.security.cert.CertificateEncodingException;
import java.util.*;
/**
* Legacy Firefox Certificate installer
*
* For old Firefox-compatible browsers still in the wild such as Firefox 52 ESR, SeaMonkey, WaterFox, etc.
*/
public class LegacyFirefoxCertificateInstaller {
private static final Logger log = LogManager.getLogger(CertificateChainBuilder.class);
private static final String CFG_TEMPLATE = "assets/firefox-autoconfig.js.in";
private static final String CFG_FILE = Constants.PROPS_FILE + ".cfg";
private static final String PREFS_FILE = Constants.PROPS_FILE + ".js";
private static final String PREFS_DIR = "defaults/pref";
private static final String MAC_PREFIX = "Contents/Resources";
public static boolean installAutoConfigScript(AppInfo appInfo, String certData, String ... hostNames) {
try {
if(appInfo.getPath().toString().equals("/usr/bin")) {
throw new Exception("Preventing install to root location");
}
writePrefsFile(appInfo);
writeParsedConfig(appInfo, certData, false, hostNames);
return true;
} catch(Exception e) {
log.warn("Error installing auto-config support for {}", appInfo, e);
}
return false;
}
public static boolean uninstallAutoConfigScript(AppInfo appInfo) {
try {
writeParsedConfig(appInfo, "", true);
return true;
} catch(Exception e) {
log.warn("Error uninstalling auto-config support for {}", appInfo, e);
}
return false;
}
public static File tryWrite(AppInfo appInfo, boolean mkdirs, String ... paths) throws IOException {
Path dir = appInfo.getPath();
if (SystemUtilities.isMac()) {
dir = dir.resolve(MAC_PREFIX);
}
for (String path : paths) {
dir = dir.resolve(path);
}
File file = dir.toFile();
if(mkdirs) file.mkdirs();
if(file.exists() && file.isDirectory() && file.canWrite()) {
return file;
}
throw new IOException(String.format("Directory does not exist or is not writable: %s", file));
}
public static void deleteFile(File parent, String ... paths) {
if(parent != null) {
String toDelete = parent.getPath();
for (String path : paths) {
toDelete += File.separator + path;
}
File deleteFile = new File(toDelete);
if (!deleteFile.exists()) {
} else if (new File(toDelete).delete()) {
log.info("Deleted old file: {}", toDelete);
} else {
log.warn("Could not delete old file: {}", toDelete);
}
}
}
public static void writePrefsFile(AppInfo app) throws Exception {
File prefsDir = tryWrite(app, true, PREFS_DIR);
deleteFile(prefsDir, "firefox-prefs.js"); // cleanup old version
// first check that there aren't other prefs files
String pref = "general.config.filename";
for (File file : prefsDir.listFiles()) {
try {
BufferedReader reader = new BufferedReader(new FileReader(file));
String line;
while((line = reader.readLine()) != null) {
if(line.contains(pref) && !line.contains(CFG_FILE)) {
throw new Exception(String.format("Browser already has %s defined in %s:\n %s", pref, file, line));
}
}
} catch(IOException ignore) {}
}
// write out the new prefs file
File prefsFile = new File(prefsDir, PREFS_FILE);
BufferedWriter writer = new BufferedWriter(new FileWriter(prefsFile));
String[] data = {
String.format("pref('%s', '%s');", pref, CFG_FILE),
"pref('general.config.obscure_value', 0);"
};
for (String line : data) {
writer.write(line + "\n");
}
writer.close();
prefsFile.setReadable(true, false);
}
private static void writeParsedConfig(AppInfo appInfo, String certData, boolean uninstall, String ... hostNames) throws IOException, CertificateEncodingException{
if (hostNames.length == 0) hostNames = CertificateChainBuilder.DEFAULT_HOSTNAMES;
File cfgDir = tryWrite(appInfo, false);
deleteFile(cfgDir, "firefox-config.cfg"); // cleanup old version
File dest = new File(cfgDir.getPath(), CFG_FILE);
HashMap<String, String> fieldMap = new HashMap<>();
// Dynamic fields
fieldMap.put("%CERT_DATA%", certData);
fieldMap.put("%COMMON_NAME%", hostNames[0]);
fieldMap.put("%TIMESTAMP%", uninstall ? "-1" : "" + new Date().getTime());
fieldMap.put("%APP_PATH%", SystemUtilities.isMac() ? SystemUtilities.getAppPath() != null ? SystemUtilities.getAppPath().toString() : "" : "");
fieldMap.put("%UNINSTALL%", "" + uninstall);
FileUtilities.configureAssetFile(CFG_TEMPLATE, dest, fieldMap, LegacyFirefoxCertificateInstaller.class);
dest.setReadable(true, false);
}
}

View File

@@ -0,0 +1,117 @@
//
// Firefox AutoConfig Certificate Installer for Legacy Firefox versions
// This is part of the QZ Tray application
//
var serviceObserver = {
observe: function observe(aSubject, aTopic, aData) {
// Get NSS certdb object
var certdb = getCertDB();
if (needsUninstall()) {
deleteCertificate();
unregisterProtocol();
} else if (needsCert()) {
deleteCertificate();
installCertificate();
registerProtocol();
}
// Compares the timestamp embedded in this script against that stored in the browser's about:config
function needsCert() {
try {
return getPref("%PROPS_FILE%.installer.timestamp") != "%TIMESTAMP%";
} catch(notfound) {}
return true;
}
// Installs the embedded base64 certificate into the browser
function installCertificate() {
certdb.addCertFromBase64(getCertData(), "C,C,C", "%COMMON_NAME% - %ABOUT_COMPANY%");
pref("%PROPS_FILE%.installer.timestamp", "%TIMESTAMP%");
}
// Deletes the certificate, if it exists
function deleteCertificate() {
var certs = certdb.getCerts();
var enumerator = certs.getEnumerator();
while (enumerator.hasMoreElements()) {
var cert = enumerator.getNext().QueryInterface(Components.interfaces.nsIX509Cert);
if (cert.containsEmailAddress("%ABOUT_EMAIL%")) {
try {
certdb.deleteCertificate(cert);
} catch (ignore) {}
}
}
pref("%PROPS_FILE%.installer.timestamp", "-1");
}
// Register the specified protocol to open with the specified application
function registerProtocol() {
// Only register if platform needs it (e.g. macOS)
var trayApp = "%APP_PATH%";
if (!trayApp) { return; }
try {
var hservice = Components.classes["@mozilla.org/uriloader/handler-service;1"].getService(Components.interfaces.nsIHandlerService);
var pservice = Components.classes["@mozilla.org/uriloader/external-protocol-service;1"].getService(Components.interfaces.nsIExternalProtocolService);
var file = Components.classes["@mozilla.org/file/local;1"].createInstance(Components.interfaces.nsIFile);
file.initWithPath(trayApp);
var lhandler = Components.classes["@mozilla.org/uriloader/local-handler-app;1"].createInstance(Components.interfaces.nsILocalHandlerApp);
lhandler.executable = file;
lhandler.name = "%PROPS_FILE%";
var protocol = pservice.getProtocolHandlerInfo("%DATA_DIR%");
protocol.preferredApplicationHandler = lhandler;
protocol.preferredAction = 2; // useHelperApp
protocol.alwaysAskBeforeHandling = false;
hservice.store(protocol);
} catch(ignore) {}
}
// De-register the specified protocol from opening with the specified application
function unregisterProtocol() {
// Only register if platform needs it (e.g. macOS)
var trayApp = "%APP_PATH%";
if (!trayApp) { return; }
try {
var hservice = Components.classes["@mozilla.org/uriloader/handler-service;1"].getService(Components.interfaces.nsIHandlerService);
var pservice = Components.classes["@mozilla.org/uriloader/external-protocol-service;1"].getService(Components.interfaces.nsIExternalProtocolService);
hservice.remove(pservice.getProtocolHandlerInfo("%DATA_DIR%"));
} catch(ignore) {}
}
// Get certdb object
function getCertDB() {
// Import certificate using NSS certdb API (http://tinyurl.com/x509certdb)
var id = "@mozilla.org/security/x509certdb;1";
var db1 = Components.classes[id].getService(Components.interfaces.nsIX509CertDB);
var db2 = db1;
try {
db2 = Components.classes[id].getService(Components.interfaces.nsIX509CertDB2);
} catch(ignore) {}
return db2;
}
// The certificate to import (automatically generated by desktop installer)
function getCertData() {
return "%CERT_DATA%";
}
// Whether or not an uninstall should occur, flagged by the installer/uninstaller
function needsUninstall() {
try {
if (getPref("%PROPS_FILE%.installer.timestamp") == "-1") {
return false;
}
}
catch(notfound) {
return false;
}
return %UNINSTALL%;
}
}
};
Components.utils.import("resource://gre/modules/Services.jsm");
Services.obs.addObserver(serviceObserver, "profile-after-change", false);

View File

@@ -0,0 +1,91 @@
package qz.installer.certificate.firefox.locator;
import java.util.Locale;
public enum AppAlias {
// Tor Browser intentionally excluded; Tor's proxy blocks localhost connections
FIREFOX(
new Alias("Mozilla", "Mozilla Firefox", "org.mozilla.firefox", true),
new Alias("Mozilla", "Firefox Developer Edition", "org.mozilla.firefoxdeveloperedition", true),
new Alias("Mozilla", "Firefox Nightly", "org.mozilla.nightly", true),
new Alias("Mozilla", "SeaMonkey", "org.mozilla.seamonkey", false),
new Alias("Waterfox", "Waterfox", "net.waterfox.waterfoxcurrent", true),
new Alias("Waterfox", "Waterfox Classic", "org.waterfoxproject.waterfox classic", false),
new Alias("Mozilla", "Pale Moon", "org.mozilla.palemoon", false),
// IceCat is technically enterprise ready, but not officially distributed for macOS, Windows
new Alias("Mozilla", "IceCat", "org.gnu.icecat", false)
);
Alias[] aliases;
AppAlias(Alias... aliases) {
this.aliases = aliases;
}
public Alias[] getAliases() {
return aliases;
}
public static Alias findAlias(AppAlias appAlias, String appName, boolean stripVendor) {
if (appName != null) {
for (Alias alias : appAlias.aliases) {
if (appName.toLowerCase(Locale.ENGLISH).matches(alias.getName(stripVendor).toLowerCase(Locale.ENGLISH))) {
return alias;
}
}
}
return null;
}
public static class Alias {
private String vendor;
private String name;
private String bundleId;
private boolean enterpriseReady;
private String posix;
public Alias(String vendor, String name, String bundleId, boolean enterpriseReady) {
this.name = name;
this.vendor = vendor;
this.bundleId = bundleId;
this.enterpriseReady = enterpriseReady;
this.posix = getName(true).replaceAll(" ", "").toLowerCase(Locale.ENGLISH);
}
public String getVendor() {
return vendor;
}
public String getName() {
return name;
}
/**
* Remove vendor prefix if exists
*/
public String getName(boolean stripVendor) {
if(stripVendor && "Mozilla".equals(vendor) && name.startsWith(vendor)) {
return name.substring(vendor.length()).trim();
}
return name;
}
public String getBundleId() {
return bundleId;
}
public String getPosix() {
return posix;
}
/**
* Returns whether or not the app is known to recognizes enterprise policies, such as GPO
*/
public boolean isEnterpriseReady() {
return enterpriseReady;
}
@Override
public String toString() {
return name;
}
}
}

View File

@@ -0,0 +1,100 @@
package qz.installer.certificate.firefox.locator;
import com.github.zafarkhaja.semver.Version;
import java.nio.file.Path;
import qz.installer.certificate.firefox.locator.AppAlias.Alias;
/**
* Container class for installed app information
*/
public class AppInfo {
private AppAlias.Alias alias;
private Path path;
private Path exePath;
private Version version;
public AppInfo(Alias alias, Path exePath, String version) {
this.alias = alias;
this.path = exePath.getParent();
this.exePath = exePath;
this.version = parseVersion(version);
}
public AppInfo(Alias alias, Path path, Path exePath, String version) {
this.alias = alias;
this.path = path;
this.exePath = exePath;
this.version = parseVersion(version);
}
public AppInfo(Alias alias, Path exePath) {
this.alias = alias;
this.path = exePath.getParent();
this.exePath = exePath;
}
public Alias getAlias() {
return alias;
}
public String getName(boolean stripVendor) {
return alias.getName(stripVendor);
}
public Path getExePath() {
return exePath;
}
public Path getPath() {
return path;
}
public void setPath(Path path) {
this.path = path;
}
public Version getVersion() {
return version;
}
public void setVersion(String version) {
this.version = parseVersion(version);
}
public void setVersion(Version version) {
this.version = version;
}
private static Version parseVersion(String version) {
try {
// Ensure < 3 octets (e.g. "56.0") doesn't failing
while(version.split("\\.").length < 3) {
version = version + ".0";
}
return Version.valueOf(version);
} catch(Exception ignore1) {
// Catch poor formatting (e.g. "97.0a1"), try to use major version only
if(version.split("\\.").length > 0) {
try {
String[] tryFix = version.split("\\.");
return Version.valueOf(tryFix[0] + ".0.0-unknown");
} catch(Exception ignore2) {}
}
}
return null;
}
@Override
public boolean equals(Object o) {
if(o instanceof AppInfo && o != null && path != null) {
return path.equals(((AppInfo)o).getPath());
}
return false;
}
@Override
public String toString() {
return alias + " " + path;
}
}

View File

@@ -0,0 +1,87 @@
package qz.installer.certificate.firefox.locator;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
public abstract class AppLocator {
protected static final Logger log = LogManager.getLogger(AppLocator.class);
private static AppLocator INSTANCE = getPlatformSpecificAppLocator();
public abstract ArrayList<AppInfo> locate(AppAlias appAlias);
public abstract ArrayList<Path> getPidPaths(ArrayList<String> pids);
@SuppressWarnings("unused")
public ArrayList<String> getPids(String ... processNames) {
return getPids(new ArrayList<>(Arrays.asList(processNames)));
}
/**
* Linux, Mac
*/
public ArrayList<String> getPids(ArrayList<String> processNames) {
String[] response;
ArrayList<String> pidList = new ArrayList<>();
if(processNames.contains("firefox") && !(SystemUtilities.isWindows() || SystemUtilities.isMac())) {
processNames.add("MainThread"); // Workaround Firefox 79 https://github.com/qzind/tray/issues/701
processNames.add("GeckoMain"); // Workaround Firefox 94 https://bugzilla.mozilla.org/show_bug.cgi?id=1742606
}
if (processNames.size() == 0) return pidList;
// Quoting handled by the command processor (e.g. pgrep -x "myapp|my app" is perfectly valid)
String data = ShellUtilities.executeRaw("pgrep", "-x", String.join("|", processNames));
//Splitting an empty string results in a 1 element array, this is not what we want
if (!data.isEmpty()) {
response = data.split("\\s*\\r?\\n");
Collections.addAll(pidList, response);
}
return pidList;
}
public static ArrayList<Path> getRunningPaths(ArrayList<AppInfo> appList) {
return getRunningPaths(appList, null);
}
/**
* Gets the path to the running executables matching on <code>AppInfo.getExePath</code>
* This is resource intensive; if a non-null <code>cache</code> is provided, it will return that instead
*/
public static ArrayList<Path> getRunningPaths(ArrayList<AppInfo> appList, ArrayList<Path> cache) {
if(cache == null) {
ArrayList<String> appNames = new ArrayList<>();
for(AppInfo app : appList) {
String exeName = app.getExePath().getFileName().toString();
if (!appNames.contains(exeName)) appNames.add(exeName);
}
cache = INSTANCE.getPidPaths(INSTANCE.getPids(appNames));
}
return cache;
}
public static AppLocator getInstance() {
return INSTANCE;
}
private static AppLocator getPlatformSpecificAppLocator() {
switch(SystemUtilities.getOs()) {
case WINDOWS:
return new WindowsAppLocator();
case MAC:
return new MacAppLocator();
default:
return new LinuxAppLocator();
}
}
}

View File

@@ -0,0 +1,159 @@
package qz.installer.certificate.firefox.locator;
import org.apache.commons.io.FilenameUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import qz.utils.UnixUtilities;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
public class LinuxAppLocator extends AppLocator {
private static final Logger log = LogManager.getLogger(LinuxAppLocator.class);
public ArrayList<AppInfo> locate(AppAlias appAlias) {
ArrayList<AppInfo> appList = new ArrayList<>();
// Workaround for calling "firefox --version" as sudo
String[] env = appendPaths("HOME=/tmp");
// Search for matching executable in all path values
aliasLoop:
for(AppAlias.Alias alias : appAlias.aliases) {
// Add non-standard app search locations (e.g. Fedora)
for (String dirname : appendPaths(alias.getPosix(), "/usr/lib/$/bin", "/usr/lib64/$/bin", "/usr/lib/$", "/usr/lib64/$")) {
Path path = Paths.get(dirname, alias.getPosix());
if (Files.isRegularFile(path) && Files.isExecutable(path)) {
log.info("Found {} {}: {}, investigating...", alias.getVendor(), alias.getName(true), path);
try {
File file = path.toFile().getCanonicalFile(); // fix symlinks
if(file.getPath().endsWith("/snap")) {
// Ubuntu 22.04+ ships Firefox as a snap
// Snaps are read-only and are symlinks back to /usr/bin/snap
// Reset the executable back to /snap/bin/firefox to get proper version information
file = path.toFile();
}
if(file.getPath().endsWith(".sh")) {
// Legacy Ubuntu likes to use .../firefox/firefox.sh, return .../firefox/firefox instead
log.info("Found an '.sh' file: {}, removing file extension: {}", file, file = new File(FilenameUtils.removeExtension(file.getPath())));
}
String contentType = Files.probeContentType(file.toPath());
if(contentType == null) {
// Fallback to commandline per https://bugs.openjdk.org/browse/JDK-8188228
contentType = ShellUtilities.executeRaw("file", "--mime-type", "--brief", file.getPath()).trim();
}
if(contentType != null && contentType.endsWith("/x-shellscript")) {
if(UnixUtilities.isFedora()) {
// Firefox's script is full of variables and not parsable, fallback to /usr/lib64/$, etc
log.info("Found shell script at {}, but we're on Fedora, so we'll look in some known locations instead.", file.getPath());
continue;
}
// Debian and Arch like to place a stub script directly in /usr/bin/
// TODO: Split into a function; possibly recurse on search paths
log.info("{} bin was expected but script found... Reading...", appAlias.name());
BufferedReader reader = new BufferedReader(new FileReader(file));
String line;
while((line = reader.readLine()) != null) {
if(line.startsWith("exec") && line.contains(alias.getPosix())) {
String[] parts = line.split(" ");
// Get the app name after "exec"
if (parts.length > 1) {
log.info("Found a familiar line '{}', using '{}'", line, parts[1]);
Path p = Paths.get(parts[1]);
String exec = parts[1];
// Handle edge-case for esr release
if(!p.isAbsolute()) {
// Script doesn't contain the full path, go deeper
exec = Paths.get(dirname, exec).toFile().getCanonicalPath();
log.info("Calculated full bin path {}", exec);
}
// Make sure it actually exists
if(!(file = new File(exec)).exists()) {
log.warn("Sorry, we couldn't detect the real path of {}. Skipping...", appAlias.name());
continue aliasLoop;
}
break;
}
}
}
reader.close();
} else {
log.info("Assuming {} {} is installed: {}", alias.getVendor(), alias.getName(true), file);
}
AppInfo appInfo = new AppInfo(alias, file.toPath());
if(file.getPath().startsWith("/snap/")) {
// Ubuntu 22.04+ uses snaps, fallback to a sane "path" value
String snapPath = file.getPath(); // e.g. /snap/bin/firefox
snapPath = snapPath.replaceFirst("/bin/", "/");
snapPath += "/current";
appInfo.setPath(Paths.get(snapPath));
}
appList.add(appInfo);
// Call "--version" on executable to obtain version information
Process p = Runtime.getRuntime().exec(new String[] {file.getPath(), "--version" }, env);
BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
String version = reader.readLine();
reader.close();
if (version != null) {
log.info("We obtained version info: {}, but we'll need to parse it", version);
if(version.contains(" ")) {
String[] split = version.split(" ");
String parsed = split[split.length - 1];
String stripped = parsed.replaceAll("[^\\d.]", "");
appInfo.setVersion(stripped);
if(!parsed.equals(stripped)) {
// Add the meta data back (e.g. "esr")
appInfo.getVersion().setBuildMetadata(parsed.replaceAll("[\\d.]", ""));
}
} else {
appInfo.setVersion(version.trim());
}
}
break;
} catch(Exception e) {
log.warn("Something went wrong getting app info for {} {}", alias.getVendor(), alias.getName(true), e);
}
}
}
}
return appList;
}
@Override
public ArrayList<Path> getPidPaths(ArrayList<String> pids) {
ArrayList<Path> pathList = new ArrayList<>();
for(String pid : pids) {
try {
pathList.add(Paths.get("/proc/", pid, !SystemUtilities.isSolaris() ? "/exe" : "/path/a.out").toRealPath());
} catch(IOException e) {
log.warn("Process {} vanished", pid);
}
}
return pathList;
}
/**
* Returns a PATH value with provided paths appended, replacing "$" with POSIX app name
* Useful for strange Firefox install locations (e.g. Fedora)
*
* Usage: appendPaths("firefox", "/usr/lib64");
*
*/
private static String[] appendPaths(String posix, String ... prefixes) {
String newPath = System.getenv("PATH");
for (String prefix : prefixes) {
newPath = newPath + File.pathSeparator + prefix.replaceAll("\\$", posix);
}
return newPath.split(File.pathSeparator);
}
}

View File

@@ -0,0 +1,168 @@
package qz.installer.certificate.firefox.locator;
import com.sun.jna.Library;
import com.sun.jna.Memory;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.ptr.IntByReference;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import qz.utils.ShellUtilities;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
public class MacAppLocator extends AppLocator{
protected static final Logger log = LogManager.getLogger(MacAppLocator.class);
private static String[] BLACKLISTED_PATHS = new String[]{"/Volumes/", "/.Trash/", "/Applications (Parallels)/" };
/**
* Helper class for finding key/value siblings from the DDM
*/
private enum SiblingNode {
NAME("_name"),
PATH("path"),
VERSION("version");
private String key;
private boolean wants;
SiblingNode(String key) {
this.key = key;
this.wants = false;
}
private boolean isKey(Node node) {
if (node.getNodeName().equals("key") && node.getTextContent().equals(key)) {
return true;
}
return false;
}
}
@Override
public ArrayList<AppInfo> locate(AppAlias appAlias) {
ArrayList<AppInfo> appList = new ArrayList<>();
Document doc;
try {
// system_profile benchmarks about 30% better than lsregister
Process p = Runtime.getRuntime().exec(new String[] {"system_profiler", "SPApplicationsDataType", "-xml"}, ShellUtilities.envp);
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
// don't let the <!DOCTYPE> fail parsing per https://github.com/qzind/tray/issues/809
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
doc = dbf.newDocumentBuilder().parse(p.getInputStream());
} catch(IOException | ParserConfigurationException | SAXException e) {
log.warn("Could not retrieve app listing for {}", appAlias.name(), e);
return appList;
}
doc.normalizeDocument();
NodeList nodeList = doc.getElementsByTagName("dict");
for (int i = 0; i < nodeList.getLength(); i++) {
NodeList dict = nodeList.item(i).getChildNodes();
HashMap<SiblingNode, String> foundApp = new HashMap<>();
for (int j = 0; j < dict.getLength(); j++) {
Node node = dict.item(j);
if (node.getNodeType() == Node.ELEMENT_NODE) {
for (SiblingNode sibling : SiblingNode.values()) {
if (sibling.wants) {
foundApp.put(sibling, node.getTextContent());
sibling.wants = false;
break;
} else if(sibling.isKey(node)) {
sibling.wants = true;
break;
}
}
}
}
AppAlias.Alias alias;
if((alias = AppAlias.findAlias(appAlias, foundApp.get(SiblingNode.NAME), true)) != null) {
appList.add(new AppInfo(alias, Paths.get(foundApp.get(SiblingNode.PATH)),
getExePath(foundApp.get(SiblingNode.PATH)), foundApp.get(SiblingNode.VERSION)
));
}
}
// Remove blacklisted paths
Iterator<AppInfo> appInfoIterator = appList.iterator();
while(appInfoIterator.hasNext()) {
AppInfo appInfo = appInfoIterator.next();
for(String listEntry : BLACKLISTED_PATHS) {
if (appInfo.getPath() != null && appInfo.getPath().toString().contains(listEntry)) {
appInfoIterator.remove();
}
}
}
return appList;
}
@Override
public ArrayList<Path> getPidPaths(ArrayList<String> pids) {
ArrayList<Path> processPaths = new ArrayList();
for (String pid : pids) {
Pointer buf = new Memory(SystemB.PROC_PIDPATHINFO_MAXSIZE);
SystemB.INSTANCE.proc_pidpath(Integer.parseInt(pid), buf, SystemB.PROC_PIDPATHINFO_MAXSIZE);
processPaths.add(Paths.get(buf.getString(0).trim()));
}
return processPaths;
}
/**
* Calculate executable path by parsing Contents/Info.plist
*/
private static Path getExePath(String appPath) {
Path path = Paths.get(appPath).toAbsolutePath().normalize();
Path plist = path.resolve("Contents/Info.plist");
Document doc;
try {
if(!plist.toFile().exists()) {
log.warn("Could not locate plist file for {}: {}", appPath, plist);
return null;
}
// Convert potentially binary plist files to XML
Process p = Runtime.getRuntime().exec(new String[] {"plutil", "-convert", "xml1", plist.toString(), "-o", "-"}, ShellUtilities.envp);
doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(p.getInputStream());
} catch(IOException | ParserConfigurationException | SAXException e) {
log.warn("Could not parse plist file for {}: {}", appPath, appPath, e);
return null;
}
doc.normalizeDocument();
boolean upNext = false;
NodeList nodeList = doc.getElementsByTagName("dict");
for (int i = 0; i < nodeList.getLength(); i++) {
NodeList dict = nodeList.item(i).getChildNodes();
for(int j = 0; j < dict.getLength(); j++) {
Node node = dict.item(j);
if ("key".equals(node.getNodeName()) && node.getTextContent().equals("CFBundleExecutable")) {
upNext = true;
} else if (upNext && "string".equals(node.getNodeName())) {
return path.resolve("Contents/MacOS/" + node.getTextContent());
}
}
}
return null;
}
private interface SystemB extends Library {
SystemB INSTANCE = Native.load("System", SystemB.class);
int PROC_ALL_PIDS = 1;
int PROC_PIDPATHINFO_MAXSIZE = 1024 * 4;
int sysctlbyname(String name, Pointer oldp, IntByReference oldlenp, Pointer newp, int newlen);
int proc_listpids(int type, int typeinfo, int[] buffer, int buffersize);
int proc_pidpath(int pid, Pointer buffer, int buffersize);
}
}

View File

@@ -0,0 +1,142 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.certificate.firefox.locator;
import com.sun.jna.Memory;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.platform.win32.Kernel32;
import com.sun.jna.platform.win32.Psapi;
import com.sun.jna.platform.win32.Tlhelp32;
import com.sun.jna.platform.win32.WinNT;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.installer.certificate.firefox.locator.AppAlias.Alias;
import qz.utils.WindowsUtilities;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Locale;
import static com.sun.jna.platform.win32.WinReg.HKEY_LOCAL_MACHINE;
public class WindowsAppLocator extends AppLocator{
protected static final Logger log = LogManager.getLogger(MacAppLocator.class);
private static String REG_TEMPLATE = "Software\\%s%s\\%s%s";
@Override
public ArrayList<AppInfo> locate(AppAlias appAlias) {
ArrayList<AppInfo> appList = new ArrayList<>();
for (Alias alias : appAlias.aliases) {
if (alias.getVendor() != null) {
String[] suffixes = new String[]{ "", " ESR"};
String[] prefixes = new String[]{ "", "WOW6432Node\\"};
for (String suffix : suffixes) {
for (String prefix : prefixes) {
String key = String.format(REG_TEMPLATE, prefix, alias.getVendor(), alias.getName(), suffix);
AppInfo appInfo = getAppInfo(alias, key, suffix);
if (appInfo != null && !appList.contains(appInfo)) {
appList.add(appInfo);
}
}
}
}
}
return appList;
}
@Override
public ArrayList<String> getPids(ArrayList<String> processNames) {
ArrayList<String> pidList = new ArrayList<>();
if (processNames.isEmpty()) return pidList;
Tlhelp32.PROCESSENTRY32 pe32 = new Tlhelp32.PROCESSENTRY32();
pe32.dwSize = new WinNT.DWORD(pe32.size());
// Fetch a snapshot of all processes
WinNT.HANDLE hSnapshot = Kernel32.INSTANCE.CreateToolhelp32Snapshot(Tlhelp32.TH32CS_SNAPPROCESS, new WinNT.DWORD(0));
if (hSnapshot.equals(WinNT.INVALID_HANDLE_VALUE)) {
log.warn("Process snapshot has invalid handle");
return pidList;
}
if (Kernel32.INSTANCE.Process32First(hSnapshot, pe32)) {
do {
String processName = Native.toString(pe32.szExeFile);
if(processNames.contains(processName.toLowerCase(Locale.ENGLISH))) {
pidList.add(pe32.th32ProcessID.toString());
}
} while (Kernel32.INSTANCE.Process32Next(hSnapshot, pe32));
}
Kernel32.INSTANCE.CloseHandle(hSnapshot);
return pidList;
}
@Override
public ArrayList<Path> getPidPaths(ArrayList<String> pids) {
ArrayList<Path> pathList = new ArrayList<>();
for(String pid : pids) {
WinNT.HANDLE hProcess = Kernel32.INSTANCE.OpenProcess(WinNT.PROCESS_QUERY_INFORMATION | WinNT.PROCESS_VM_READ, false, Integer.parseInt(pid));
if (hProcess == null) {
log.warn("Handle for PID {} is missing, skipping.", pid);
continue;
}
int bufferSize = WinNT.MAX_PATH;
Pointer buffer = new Memory(bufferSize * Native.WCHAR_SIZE);
if (Psapi.INSTANCE.GetModuleFileNameEx(hProcess, null, buffer, bufferSize) == 0) {
log.warn("Full path to PID {} is empty, skipping.", pid);
Kernel32.INSTANCE.CloseHandle(hProcess);
continue;
}
Kernel32.INSTANCE.CloseHandle(hProcess);
pathList.add(Paths.get(Native.WCHAR_SIZE == 1 ?
buffer.getString(0) :
buffer.getWideString(0)));
}
return pathList;
}
/**
* Use a proprietary Firefox-only technique for getting "PathToExe" registry value
*/
private static AppInfo getAppInfo(Alias alias, String key, String suffix) {
String version = WindowsUtilities.getRegString(HKEY_LOCAL_MACHINE, key, "CurrentVersion");
if (version != null) {
version = version.split(" ")[0]; // chop off (x86 ...)
if (!suffix.isEmpty()) {
if (key.endsWith(suffix)) {
key = key.substring(0, key.length() - suffix.length());
}
version = version + suffix;
}
String exePath = WindowsUtilities.getRegString(HKEY_LOCAL_MACHINE, key + " " + version + "\\bin", "PathToExe");
if (exePath != null) {
// SemVer: Replace spaces in suffixes with dashes
version = version.replaceAll(" ", "-");
return new AppInfo(alias, Paths.get(exePath), version);
} else {
log.warn("Couldn't locate \"PathToExe\" for \"{}\" in \"{}\", skipping", alias.getName(), key);
}
}
return null;
}
}

View File

@@ -0,0 +1,161 @@
package qz.installer.provision;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import qz.build.provision.Step;
import qz.build.provision.params.Os;
import qz.build.provision.params.Phase;
import qz.build.provision.params.types.Script;
import qz.build.provision.params.types.Software;
import qz.common.Constants;
import qz.installer.provision.invoker.*;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import static qz.common.Constants.*;
import static qz.utils.FileUtilities.*;
public class ProvisionInstaller {
protected static final Logger log = LogManager.getLogger(ProvisionInstaller.class);
private ArrayList<Step> steps;
static {
// Populate variables for scripting environment
ShellUtilities.addEnvp("APP_TITLE", ABOUT_TITLE,
"APP_VERSION", VERSION,
"APP_ABBREV", PROPS_FILE,
"APP_VENDOR", ABOUT_COMPANY,
"APP_VENDOR_ABBREV", DATA_DIR,
"APP_ARCH", SystemUtilities.getArch(),
"APP_OS", SystemUtilities.getOs(),
"APP_DIR", SystemUtilities.getAppPath(),
"APP_USER_DIR", USER_DIR,
"APP_SHARED_DIR", SHARED_DIR);
}
public ProvisionInstaller(Path relativePath) throws IOException, JSONException {
this(relativePath, relativePath.resolve(Constants.PROVISION_FILE).toFile());
}
public ProvisionInstaller(Path relativePath, File jsonFile) throws IOException, JSONException {
if(!jsonFile.exists()) {
log.info("Provision file not found '{}', skipping", jsonFile);
this.steps = new ArrayList<>();
return;
}
this.steps = parse(FileUtils.readFileToString(jsonFile, StandardCharsets.UTF_8), relativePath);
}
/**
* Package private for internal testing only
* Assumes files located in ./resources/ subdirectory
*/
ProvisionInstaller(Class relativeClass, InputStream in) throws IOException, JSONException {
this(relativeClass, IOUtils.toString(in, StandardCharsets.UTF_8));
}
/**
* Package private for internal testing only
* Assumes files located in ./resources/ subdirectory
*/
ProvisionInstaller(Class relativeClass, String jsonData) throws JSONException {
this.steps = parse(jsonData, relativeClass);
}
public void invoke(Phase phase) {
for(Step step : this.steps) {
if(phase == null || step.getPhase() == phase) {
try {
invokeStep(step);
}
catch(Exception e) {
log.error("[PROVISION] Provisioning step failed '{}'", step, e);
}
}
}
}
public void invoke() {
invoke(null);
}
private static ArrayList<Step> parse(String jsonData, Object relativeObject) throws JSONException {
return parse(new JSONArray(jsonData), relativeObject);
}
private boolean invokeStep(Step step) throws Exception {
if(Os.matchesHost(step.getOs())) {
log.info("[PROVISION] Invoking step '{}'", step.toString());
} else {
log.info("[PROVISION] Skipping step for different OS '{}'", step.toString());
return false;
}
Invokable invoker;
switch(step.getType()) {
case CA:
invoker = new CaInvoker(step, PropertyInvoker.getProperties(step));
break;
case CERT:
invoker = new CertInvoker(step);
break;
case CONF:
invoker = new ConfInvoker(step);
break;
case SCRIPT:
invoker = new ScriptInvoker(step);
break;
case SOFTWARE:
invoker = new SoftwareInvoker(step);
break;
case REMOVER:
invoker = new RemoverInvoker(step);
break;
case RESOURCE:
invoker = new ResourceInvoker(step);
break;
case PREFERENCE:
invoker = new PropertyInvoker(step, PropertyInvoker.getPreferences(step));
break;
case PROPERTY:
invoker = new PropertyInvoker(step, PropertyInvoker.getProperties(step));
break;
default:
throw new UnsupportedOperationException("Type " + step.getType() + " is not yet supported.");
}
return invoker.invoke();
}
public ArrayList<Step> getSteps() {
return steps;
}
private static ArrayList<Step> parse(JSONArray jsonArray, Object relativeObject) throws JSONException {
ArrayList<Step> steps = new ArrayList<>();
for(int i = 0; i < jsonArray.length(); i++) {
JSONObject jsonStep = jsonArray.getJSONObject(i);
try {
steps.add(Step.parse(jsonStep, relativeObject));
} catch(Exception e) {
log.warn("[PROVISION] Unable to add step '{}'", jsonStep, e);
}
}
return steps;
}
public static boolean shouldBeExecutable(Path path) {
return Script.parse(path) != null || Software.parse(path) != Software.UNKNOWN;
}
}

View File

@@ -0,0 +1,49 @@
package qz.installer.provision.invoker;
import qz.build.provision.Step;
import qz.common.PropertyHelper;
import qz.utils.ArgValue;
import qz.utils.FileUtilities;
import java.io.File;
import java.io.IOException;
/**
* Combines ResourceInvoker and PropertyInvoker to deploy a file and set a property to its deployed path
*/
public class CaInvoker extends InvokableResource {
Step step;
PropertyHelper properties;
public CaInvoker(Step step, PropertyHelper properties) {
this.step = step;
this.properties = properties;
}
@Override
public boolean invoke() throws IOException {
// First, write our cert file
File caCert = dataToFile(step);
if(caCert == null) {
return false;
}
// Next, handle our property step
Step propsStep = step.clone();
// If the property already exists, snag it
String key = ArgValue.AUTHCERT_OVERRIDE.getMatch();
String value = caCert.getPath();
if (properties.containsKey(key)) {
value = properties.getProperty(key) + FileUtilities.FILE_SEPARATOR + value;
}
propsStep.setData(String.format("%s=%s", key, value));
if (new PropertyInvoker(propsStep, properties).invoke()) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,26 @@
package qz.installer.provision.invoker;
import qz.build.provision.Step;
import qz.common.Constants;
import qz.utils.FileUtilities;
import java.io.File;
import static qz.utils.ArgParser.ExitStatus.*;
public class CertInvoker extends InvokableResource {
private Step step;
public CertInvoker(Step step) {
this.step = step;
}
@Override
public boolean invoke() throws Exception {
File cert = dataToFile(step);
if(cert == null) {
return false;
}
return FileUtilities.addToCertList(Constants.ALLOW_FILE, cert) == SUCCESS;
}
}

View File

@@ -0,0 +1,46 @@
package qz.installer.provision.invoker;
import qz.build.provision.Step;
import qz.common.PropertyHelper;
import qz.utils.SystemUtilities;
import java.util.AbstractMap;
public class ConfInvoker extends PropertyInvoker {
public ConfInvoker(Step step) {
super(step, new PropertyHelper(calculateConfPath(step)));
}
public static String calculateConfPath(Step step) {
String relativePath = step.getArgs().get(0);
if(SystemUtilities.isMac()) {
return SystemUtilities.getJarParentPath().
resolve("../PlugIns/Java.runtime/Contents/Home/conf").
resolve(relativePath).
normalize()
.toString();
} else {
return SystemUtilities.getJarParentPath()
.resolve("runtime/conf")
.resolve(relativePath)
.normalize()
.toString();
}
}
@Override
public boolean invoke() {
Step step = getStep();
// Java uses the same "|" delimiter as we do, only parse one property at a time
AbstractMap.SimpleEntry<String, String> pair = parsePropertyPair(step, step.getData());
if (!pair.getValue().isEmpty()) {
properties.setProperty(pair);
if (properties.save()) {
log.info("Successfully provisioned '1' '{}'", step.getType());
return true;
}
log.error("An error occurred saving properties '{}' to file", step.getData());
}
return false;
}
}

View File

@@ -0,0 +1,10 @@
package qz.installer.provision.invoker;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public interface Invokable {
Logger log = LogManager.getLogger(Invokable.class);
boolean invoke() throws Exception;
}

View File

@@ -0,0 +1,63 @@
package qz.installer.provision.invoker;
import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.build.provision.Step;
import qz.build.provision.params.Type;
import qz.common.Constants;
import qz.utils.SystemUtilities;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
public abstract class InvokableResource implements Invokable {
static final Logger log = LogManager.getLogger(InvokableResource.class);
public static File dataToFile(Step step) throws IOException {
Path resourcePath = Paths.get(step.getData());
if(resourcePath.isAbsolute() || step.usingPath()) {
return pathResourceToFile(step);
}
if(step.usingClass()) {
return classResourceToFile(step);
}
return null;
}
/**
* Resolves the resource directly from file
*/
private static File pathResourceToFile(Step step) {
String resourcePath = step.getData();
Path dataPath = Paths.get(resourcePath);
return dataPath.isAbsolute() ? dataPath.toFile() : step.getRelativePath().resolve(resourcePath).toFile();
}
/**
* Copies resource from JAR to a temp file for use in installation
*/
private static File classResourceToFile(Step step) throws IOException {
// Resource may be inside the jar
InputStream in = step.getRelativeClass().getResourceAsStream("resources/" + step.getData());
if(in == null) {
log.warn("Resource '{}' is missing, skipping step", step.getData());
return null;
}
String suffix = "_" + Paths.get(step.getData()).getFileName().toString();
File destination = File.createTempFile(Constants.DATA_DIR + "_provision_", suffix);
Files.copy(in, destination.toPath(), StandardCopyOption.REPLACE_EXISTING);
IOUtils.closeQuietly(in);
// Set scripts executable
if(step.getType() == Type.SCRIPT && !SystemUtilities.isWindows()) {
destination.setExecutable(true, false);
}
return destination;
}
}

View File

@@ -0,0 +1,99 @@
package qz.installer.provision.invoker;
import qz.build.provision.Step;
import qz.common.Constants;
import qz.common.PropertyHelper;
import qz.utils.FileUtilities;
import qz.utils.SystemUtilities;
import java.io.File;
import java.util.AbstractMap;
import java.util.HashMap;
import java.util.Map;
public class PropertyInvoker implements Invokable {
private Step step;
PropertyHelper properties;
public PropertyInvoker(Step step, PropertyHelper properties) {
this.step = step;
this.properties = properties;
}
public boolean invoke() {
HashMap<String, String> pairs = parsePropertyPairs(step);
if (!pairs.isEmpty()) {
for(Map.Entry<String, String> pair : pairs.entrySet()) {
properties.setProperty(pair);
}
if (properties.save()) {
log.info("Successfully provisioned '{}' '{}'", pairs.size(), step.getType());
return true;
}
log.error("An error occurred saving properties '{}' to file", step.getData());
}
return false;
}
public static PropertyHelper getProperties(Step step) {
File propertiesFile;
if(step.getRelativePath() != null) {
// Assume qz-tray.properties is one directory up from provision folder
// required to prevent installing to payload
propertiesFile = step.getRelativePath().getParent().resolve(Constants.PROPS_FILE + ".properties").toFile();
} else {
// If relative path isn't set, fallback to the jar's parent path
propertiesFile = SystemUtilities.getJarParentPath(".").resolve(Constants.PROPS_FILE + ".properties").toFile();
}
log.info("Provisioning '{}' to properties file: '{}'", step.getData(), propertiesFile);
return new PropertyHelper(propertiesFile);
}
public static PropertyHelper getPreferences(Step step) {
return new PropertyHelper(FileUtilities.USER_DIR + File.separator + Constants.PREFS_FILE + ".properties");
}
public static HashMap<String, String> parsePropertyPairs(Step step) {
HashMap<String, String> pairs = new HashMap<>();
if(step.getData() != null && !step.getData().trim().isEmpty()) {
String[] props = step.getData().split("\\|");
for(String prop : props) {
AbstractMap.SimpleEntry<String,String> pair = parsePropertyPair(step, prop);
if (pair != null) {
if(pairs.get(pair.getKey()) != null) {
log.warn("Property {} already exists, replacing [before: {}, after: {}] ",
pair.getKey(), pairs.get(pair.getKey()), pair.getValue());
}
pairs.put(pair.getKey(), pair.getValue());
}
}
} else {
log.error("Skipping Step '{}', Data is null or empty", step.getType());
}
return pairs;
}
public static AbstractMap.SimpleEntry<String, String> parsePropertyPair(Step step, String prop) {
if(prop.contains("=")) {
String[] pair = prop.split("=", 2);
if (!pair[0].trim().isEmpty()) {
if (!pair[1].trim().isEmpty()) {
return new AbstractMap.SimpleEntry<>(pair[0], pair[1]);
} else {
log.warn("Skipping '{}' '{}', property value is malformed", step.getType(), prop);
}
} else {
log.warn("Skipping '{}' '{}', property name is malformed", step.getType(), prop);
}
} else {
log.warn("Skipping '{}' '{}', property is malformed", step.getType(), prop);
}
return null;
}
public Step getStep() {
return step;
}
}

View File

@@ -0,0 +1,100 @@
package qz.installer.provision.invoker;
import qz.build.provision.Step;
import qz.build.provision.params.Os;
import qz.build.provision.params.types.Remover;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
public class RemoverInvoker extends InvokableResource {
private Step step;
private String aboutTitle; // e.g. "QZ Tray"
private String propsFile; // e.g. "qz-tray"
private String dataDir; // e.g. "qz"
public RemoverInvoker(Step step) {
this.step = step;
Remover remover = Remover.parse(step.getData());
if(remover == Remover.CUSTOM) {
// Fields are comma delimited in the data field
parseCustomFromData(step.getData());
} else {
aboutTitle = remover.getAboutTitle();
propsFile = remover.getPropsFile();
dataDir = remover.getDataDir();
}
}
@Override
public boolean invoke() throws Exception {
ArrayList<String> command = getRemoveCommand();
if(command.size() == 0) {
log.info("An existing installation of '{}' was not found. Skipping.", aboutTitle);
return true;
}
boolean success = ShellUtilities.execute(command.toArray(new String[command.size()]));
if(!success) {
log.error("An error occurred invoking [{}]", step.getData());
}
return success;
}
public void parseCustomFromData(String data) {
String[] parts = data.split(",");
aboutTitle = parts[0].trim();
propsFile = parts[1].trim();
dataDir = parts[2].trim();
}
/**
* Returns the installer command (including the installer itself and if needed, arguments) to
* invoke the installer file
*/
public ArrayList<String> getRemoveCommand() {
ArrayList<String> removeCmd = new ArrayList<>();
Os os = SystemUtilities.getOs();
switch(os) {
case WINDOWS:
Path win = Paths.get(System.getenv("PROGRAMFILES"))
.resolve(aboutTitle)
.resolve("uninstall.exe");
if(win.toFile().exists()) {
removeCmd.add(win.toString());
removeCmd.add("/S");
break;
}
case MAC:
Path legacy = Paths.get("/Applications")
.resolve(aboutTitle + ".app")
.resolve("Contents")
.resolve("uninstall");
Path mac = Paths.get("/Applications")
.resolve(aboutTitle + ".app")
.resolve("Contents")
.resolve("Resources")
.resolve("uninstall");
if(legacy.toFile().exists()) {
removeCmd.add(legacy.toString());
} else if(mac.toFile().exists()) {
removeCmd.add(mac.toString());
}
break;
default:
Path linux = Paths.get("/opt")
.resolve(propsFile)
.resolve("uninstall");
if(linux.toFile().exists()) {
removeCmd.add(linux.toString());
}
}
return removeCmd;
}
}

View File

@@ -0,0 +1,19 @@
package qz.installer.provision.invoker;
import qz.build.provision.Step;
/**
* Stub class for deploying an otherwise "action-less" resource, only to be used by other tasks
*/
public class ResourceInvoker extends InvokableResource {
private Step step;
public ResourceInvoker(Step step) {
this.step = step;
}
@Override
public boolean invoke() throws Exception {
return dataToFile(step) != null;
}
}

View File

@@ -0,0 +1,77 @@
package qz.installer.provision.invoker;
import qz.build.provision.Step;
import qz.build.provision.params.Os;
import qz.build.provision.params.types.Script;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import java.io.File;
import java.util.ArrayList;
public class ScriptInvoker extends InvokableResource {
private Step step;
public ScriptInvoker(Step step) {
this.step = step;
}
@Override
public boolean invoke() throws Exception {
File script = dataToFile(step);
if(script == null) {
return false;
}
Script engine = Script.parse(step.getData());
ArrayList<String> command = getInterpreter(engine);
if(command.isEmpty() && SystemUtilities.isWindows()) {
log.warn("No interpreter found for {}, skipping", step.getData());
return false;
}
command.add(script.toString());
boolean success = ShellUtilities.execute(command.toArray(new String[command.size()]));
if(!success) {
log.error("An error occurred invoking [{}]", step.getData());
}
return success;
}
/**
* Returns the interpreter command (and if needed, arguments) to invoke the script file
*
* An empty array will fall back to Unix "shebang" notation, e.g. #!/usr/bin/python3
* which will allow the OS to select the correct interpreter for the given file
*
* No special attention is given to "shebang", behavior may differ between OSs
*/
private static ArrayList<String> getInterpreter(Script engine) {
ArrayList<String> interpreter = new ArrayList<>();
Os osType = SystemUtilities.getOs();
switch(engine) {
case PS1:
if(osType == Os.WINDOWS) {
interpreter.add("powershell.exe");
} else if(osType == Os.MAC) {
interpreter.add("/usr/local/bin/pwsh");
} else {
interpreter.add("pwsh");
}
interpreter.add("-File");
break;
case PY:
interpreter.add(osType == Os.WINDOWS ? "python3.exe" : "python3");
break;
case BAT:
interpreter.add(osType == Os.WINDOWS ? "cmd.exe" : "wineconsole");
break;
case RB:
interpreter.add(osType == Os.WINDOWS ? "ruby.exe" : "ruby");
break;
case SH:
default:
// Allow the environment to parse it from the shebang at invocation time
}
return interpreter;
}
}

View File

@@ -0,0 +1,87 @@
package qz.installer.provision.invoker;
import qz.build.provision.Step;
import qz.build.provision.params.Os;
import qz.build.provision.params.types.Software;
import qz.utils.ShellUtilities;
import qz.utils.SystemUtilities;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
public class SoftwareInvoker extends InvokableResource {
private Step step;
public SoftwareInvoker(Step step) {
this.step = step;
}
@Override
public boolean invoke() throws Exception {
File payload = dataToFile(step);
if(payload == null) {
return false;
}
Software installer = Software.parse(step.getData());
ArrayList<String> command = getInstallCommand(installer, step.getArgs(), payload);
boolean success = ShellUtilities.execute(command.toArray(new String[command.size()]), payload.getParentFile());
if(!success) {
log.error("An error occurred invoking [{}]", step.getData());
}
return success;
}
/**
* Returns the installer command (including the installer itself and if needed, arguments) to
* invoke the installer file
*/
public ArrayList<String> getInstallCommand(Software installer, List<String> args, File payload) {
ArrayList<String> interpreter = new ArrayList<>();
Os os = SystemUtilities.getOs();
switch(installer) {
case EXE:
if(!SystemUtilities.isWindows()) {
interpreter.add("wine");
}
// Executable on its own
interpreter.add(payload.toString());
interpreter.addAll(args); // Assume exe args come after payload
break;
case MSI:
interpreter.add(os == Os.WINDOWS ? "msiexec.exe" : "msiexec");
interpreter.add("/i"); // Assume standard install
interpreter.add(payload.toString());
interpreter.addAll(args); // Assume msiexec args come after payload
break;
case PKG:
if(os == Os.MAC) {
interpreter.add("installer");
interpreter.addAll(args); // Assume installer args come before payload
interpreter.add("-package");
interpreter.add(payload.toString());
interpreter.add("-target");
interpreter.add("/"); // Assume we don't want this on a removable volume
} else {
throw new UnsupportedOperationException("PKG is not yet supported on this platform");
}
break;
case DMG:
// DMG requires "hdiutil attach", but the mount point is unknown
throw new UnsupportedOperationException("DMG is not yet supported");
case RUN:
if(SystemUtilities.isWindows()) {
interpreter.add("bash");
interpreter.add("-c");
}
interpreter.add(payload.toString());
interpreter.addAll(args); // Assume run args come after payload
// Executable on its own
break;
default:
// We'll try to parse it from the shebang just before invocation time
}
return interpreter;
}
}

View File

@@ -0,0 +1,44 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.shortcut;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.installer.LinuxInstaller;
/**
* @author Tres Finocchiaro
*/
class LinuxShortcutCreator extends ShortcutCreator {
private static final Logger log = LogManager.getLogger(LinuxShortcutCreator.class);
private static String DESKTOP = System.getProperty("user.home") + "/Desktop/";
public boolean canAutoStart() {
return Files.exists(Paths.get(LinuxInstaller.STARTUP_DIR, LinuxInstaller.SHORTCUT_NAME));
}
public void createDesktopShortcut() {
copyShortcut(LinuxInstaller.APP_LAUNCHER, DESKTOP);
}
private static void copyShortcut(String source, String target) {
try {
Files.copy(Paths.get(source), Paths.get(target));
} catch(IOException e) {
log.warn("Error creating shortcut {}", target, e);
}
}
}

View File

@@ -0,0 +1,100 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.shortcut;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import qz.common.Constants;
import qz.utils.MacUtilities;
import qz.utils.SystemUtilities;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* @author Tres Finocchiaro
*/
class MacShortcutCreator extends ShortcutCreator {
private static final Logger log = LogManager.getLogger(MacShortcutCreator.class);
private static String SHORTCUT_PATH = System.getProperty("user.home") + "/Desktop/" + Constants.ABOUT_TITLE;
/**
* Verify LaunchAgents plist file exists and parse it to verify it's enabled
*/
@Override
public boolean canAutoStart() {
// plist is stored as io.qz.plist
Path plistPath = Paths.get("/Library/LaunchAgents", MacUtilities.getBundleId() + ".plist");
if (Files.exists(plistPath)) {
try {
DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
Document doc = dBuilder.parse(plistPath.toFile());
doc.getDocumentElement().normalize();
NodeList dictList = doc.getElementsByTagName("dict");
// Loop to find "RunAtLoad" key, then the adjacent key
boolean foundItem = false;
if (dictList.getLength() > 0) {
NodeList children = dictList.item(0).getChildNodes();
for(int n = 0; n < children.getLength(); n++) {
Node item = children.item(n);
// Apple stores booleans as adjacent tags to their owner
if (foundItem) {
String nodeName = children.item(n).getNodeName();
log.debug("Found RunAtLoad value {}", nodeName);
return "true".equals(nodeName);
}
if (item.getNodeName().equals("key") && item.getTextContent().equals("RunAtLoad")) {
log.debug("Found RunAtLoad key in {}", plistPath);
foundItem = true;
}
}
}
log.warn("RunAtLoad was not in plist {}, autostart will not work.", plistPath);
}
catch(SAXException | IOException | ParserConfigurationException e) {
log.warn("Error reading plist {}, autostart will not work.", plistPath, e);
}
} else {
log.warn("No plist {} found, autostart will not work", plistPath);
}
return false;
}
public void createDesktopShortcut() {
try {
new File(SHORTCUT_PATH).delete();
if(SystemUtilities.getJarParentPath().endsWith("Contents")) {
// We're probably running from an .app bundle
Files.createSymbolicLink(Paths.get(SHORTCUT_PATH), SystemUtilities.getAppPath());
} else {
// We're running from a mystery location, use the jar instead
Files.createSymbolicLink(Paths.get(SHORTCUT_PATH), SystemUtilities.getJarPath());
}
} catch(IOException e) {
log.warn("Could not create desktop shortcut {}", SHORTCUT_PATH, e);
}
}
}

View File

@@ -0,0 +1,41 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.installer.shortcut;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.SystemUtilities;
/**
* Utility class for creating, querying and removing startup shortcuts and
* desktop shortcuts.
*
* @author Tres Finocchiaro
*/
public abstract class ShortcutCreator {
private static ShortcutCreator instance;
protected static final Logger log = LogManager.getLogger(ShortcutCreator.class);
public abstract boolean canAutoStart();
public abstract void createDesktopShortcut();
public static ShortcutCreator getInstance() {
if (instance == null) {
if (SystemUtilities.isWindows()) {
instance = new WindowsShortcutCreator();
} else if (SystemUtilities.isMac()) {
instance = new MacShortcutCreator();
} else {
instance = new LinuxShortcutCreator();
}
}
return instance;
}
}

View File

@@ -0,0 +1,60 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*
*/
package qz.installer.shortcut;
import com.sun.jna.platform.win32.Win32Exception;
import mslinks.ShellLinkException;
import mslinks.ShellLinkHelper;
import qz.common.Constants;
import qz.installer.WindowsSpecialFolders;
import qz.utils.SystemUtilities;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
/**
* @author Tres Finocchiaro
*/
public class WindowsShortcutCreator extends ShortcutCreator {
private static String SHORTCUT_NAME = Constants.ABOUT_TITLE + ".lnk";
public void createDesktopShortcut() {
createShortcut(WindowsSpecialFolders.DESKTOP.toString());
}
public boolean canAutoStart() {
try {
return Files.exists(Paths.get(WindowsSpecialFolders.COMMON_STARTUP.toString(), SHORTCUT_NAME));
} catch(Win32Exception e) {
log.warn("An exception occurred locating the startup folder; autostart cannot be determined.", e);
}
return false;
}
private void createShortcut(String folderPath) {
try {
ShellLinkHelper.createLink(getAppPath(), folderPath + File.separator + SHORTCUT_NAME);
}
catch(ShellLinkException | IOException ex) {
log.warn("Error creating desktop shortcut", ex);
}
}
/**
* Calculates .exe path from .jar
* fixme: overlaps SystemUtilities.getAppPath
*/
private static String getAppPath() {
return SystemUtilities.getJarPath().toString().replaceAll(".jar$", ".exe");
}
}

View File

@@ -0,0 +1,752 @@
package qz.printer;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.utils.LoggerUtilities;
import qz.utils.PrintingUtilities;
import qz.utils.SystemUtilities;
import javax.print.attribute.ResolutionSyntax;
import javax.print.attribute.Size2DSyntax;
import javax.print.attribute.standard.Chromaticity;
import javax.print.attribute.standard.OrientationRequested;
import javax.print.attribute.standard.PrinterResolution;
import javax.print.attribute.standard.Sides;
import java.awt.*;
import java.awt.print.PageFormat;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class PrintOptions {
private static final Logger log = LogManager.getLogger(PrintOptions.class);
private Pixel psOptions = new Pixel();
private Raw rawOptions = new Raw();
private Default defOptions = new Default();
/**
* Parses the provided JSON Object into relevant Pixel and Raw options
*/
public PrintOptions(JSONObject configOpts, PrintOutput output, PrintingUtilities.Format format) {
if (configOpts == null) { return; }
//check for raw options
if (!configOpts.isNull("forceRaw")) {
rawOptions.forceRaw = configOpts.optBoolean("forceRaw", false);
} else if (!configOpts.isNull("altPrinting")) {
log.warn("Raw option \"altPrinting\" is deprecated. Please use \"forceRaw\" instead.");
rawOptions.forceRaw = configOpts.optBoolean("altPrinting", false);
}
if (rawOptions.forceRaw && SystemUtilities.isWindows()) {
log.warn("Forced raw printing is not supported on Windows");
rawOptions.forceRaw = false;
}
if (!configOpts.isNull("encoding")) {
JSONObject encodings = configOpts.optJSONObject("encoding");
if (encodings != null) {
rawOptions.srcEncoding = encodings.optString("from", null);
rawOptions.destEncoding = encodings.optString("to", null);
} else {
rawOptions.destEncoding = configOpts.optString("encoding", null);
}
}
if (!configOpts.isNull("spool")) {
JSONObject spool = configOpts.optJSONObject("spool");
if (spool != null) {
if (!spool.isNull("size")) {
try { rawOptions.spoolSize = spool.getInt("size"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "spool.size", spool.opt("size")); }
}
// TODO: Implement spool.start
if (!spool.isNull("end")) {
rawOptions.spoolEnd = spool.optString("end");
}
} else {
LoggerUtilities.optionWarn(log, "JSONObject", "spool", configOpts.opt("spool"));
}
} else {
// Deprecated
if (!configOpts.isNull("perSpool")) {
try { rawOptions.spoolSize = configOpts.getInt("perSpool"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "perSpool", configOpts.opt("perSpool")); }
}
if (!configOpts.isNull("endOfDoc")) {
rawOptions.spoolEnd = configOpts.optString("endOfDoc", null);
}
}
if (!configOpts.isNull("copies")) {
try { rawOptions.copies = configOpts.getInt("copies"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "copies", configOpts.opt("copies")); }
}
if (!configOpts.isNull("jobName")) {
rawOptions.jobName = configOpts.optString("jobName", null);
}
if (!configOpts.isNull("retainTemp")) {
rawOptions.retainTemp = configOpts.optBoolean("retainTemp", false);
}
//check for pixel options
if (!configOpts.isNull("units")) {
switch(configOpts.optString("units")) {
case "mm":
psOptions.units = Unit.MM; break;
case "cm":
psOptions.units = Unit.CM; break;
case "in":
psOptions.units = Unit.INCH; break;
default:
LoggerUtilities.optionWarn(log, "valid value", "units", configOpts.opt("units")); break;
}
}
if (!configOpts.isNull("bounds")) {
try {
JSONObject bounds = configOpts.getJSONObject("bounds");
psOptions.bounds = new Bounds(bounds.optDouble("x", 0), bounds.optDouble("y", 0), bounds.optDouble("width", 0), bounds.optDouble("height", 0));
}
catch(JSONException e) {
LoggerUtilities.optionWarn(log, "JSONObject", "bounds", configOpts.opt("bounds"));
}
}
if (!configOpts.isNull("colorType")) {
try {
psOptions.colorType = ColorType.valueOf(configOpts.optString("colorType").toUpperCase(Locale.ENGLISH));
}
catch(IllegalArgumentException e) {
LoggerUtilities.optionWarn(log, "valid value", "colorType", configOpts.opt("colorType"));
}
}
if (!configOpts.isNull("copies")) {
try { psOptions.copies = configOpts.getInt("copies"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "copies", configOpts.opt("copies")); }
if (psOptions.copies < 1) {
log.warn("Cannot have less than one copy");
psOptions.copies = 1;
}
}
if (!configOpts.isNull("density")) {
JSONObject asymmDPI = configOpts.optJSONObject("density");
if (asymmDPI != null) {
psOptions.density = asymmDPI.optInt("feed");
psOptions.crossDensity = asymmDPI.optInt("cross");
} else {
List<PrinterResolution> rSupport = output.isSetService()?
output.getNativePrinter().getResolutions():new ArrayList<>();
JSONArray possibleDPIs = configOpts.optJSONArray("density");
if (possibleDPIs != null && possibleDPIs.length() > 0) {
PrinterResolution usableRes = null;
if (!rSupport.isEmpty()) {
for(int i = 0; i < possibleDPIs.length(); i++) {
PrinterResolution compareRes;
asymmDPI = possibleDPIs.optJSONObject(i);
if (asymmDPI != null) {
compareRes = new PrinterResolution(asymmDPI.optInt("cross"), asymmDPI.optInt("feed"), psOptions.units.resSyntax);
} else {
compareRes = new PrinterResolution(possibleDPIs.optInt(i), possibleDPIs.optInt(i), psOptions.units.resSyntax);
}
if (rSupport.contains(compareRes)) {
usableRes = compareRes;
break;
}
}
}
if (usableRes == null) {
log.warn("Supported printer densities not found, using first value provided");
asymmDPI = possibleDPIs.optJSONObject(0);
if (asymmDPI != null) {
psOptions.density = asymmDPI.optInt("feed");
psOptions.crossDensity = asymmDPI.optInt("cross");
} else {
psOptions.density = possibleDPIs.optInt(0);
}
} else {
psOptions.density = usableRes.getFeedResolution(psOptions.units.resSyntax);
psOptions.crossDensity = usableRes.getCrossFeedResolution(psOptions.units.resSyntax);
}
} else {
String relDPI = configOpts.optString("density", "").toLowerCase(Locale.ENGLISH);
if ("best".equals(relDPI)) {
PrinterResolution bestRes = null;
for(PrinterResolution pr : rSupport) {
if (bestRes == null || !pr.lessThanOrEquals(bestRes)) {
bestRes = pr;
}
}
if (bestRes != null) {
psOptions.density = bestRes.getFeedResolution(psOptions.units.resSyntax);
psOptions.crossDensity = bestRes.getCrossFeedResolution(psOptions.units.resSyntax);
} else {
log.warn("No print densities were found; density: \"{}\" is being ignored", relDPI);
}
} else if ("draft".equals(relDPI)) {
PrinterResolution lowestRes = null;
for(PrinterResolution pr : rSupport) {
if (lowestRes == null || pr.lessThanOrEquals(lowestRes)) {
lowestRes = pr;
}
}
if (lowestRes != null) {
psOptions.density = lowestRes.getFeedResolution(psOptions.units.resSyntax);
psOptions.crossDensity = lowestRes.getCrossFeedResolution(psOptions.units.resSyntax);
} else {
log.warn("No print densities were found; density: \"{}\" is being ignored", relDPI);
}
} else {
try { psOptions.density = configOpts.getDouble("density"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "density", configOpts.opt("density")); }
}
}
}
}
if (!configOpts.isNull("dithering")) {
try {
if (configOpts.getBoolean("dithering")) {
psOptions.dithering = RenderingHints.VALUE_DITHER_ENABLE;
} else {
psOptions.dithering = RenderingHints.VALUE_DITHER_DISABLE;
}
}
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "dithering", configOpts.opt("dithering")); }
}
if (!configOpts.isNull("duplex")) {
try {
if (configOpts.getBoolean("duplex")) {
psOptions.duplex = Sides.DUPLEX;
}
}
catch(JSONException e) {
//not a boolean, try as a string
try {
String duplex = configOpts.getString("duplex").toLowerCase(Locale.ENGLISH);
if (duplex.matches("^(duplex|(two.sided.)?long(.edge)?)$")) {
psOptions.duplex = Sides.DUPLEX;
} else if (duplex.matches("^(tumble|(two.sided.)?short(.edge)?)$")) {
psOptions.duplex = Sides.TUMBLE;
}
//else - one sided (default)
}
catch(JSONException e2) { LoggerUtilities.optionWarn(log, "valid value", "duplex", configOpts.opt("duplex")); }
}
}
if (!configOpts.isNull("interpolation")) {
switch(configOpts.optString("interpolation")) {
case "bicubic":
psOptions.interpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC; break;
case "bilinear":
psOptions.interpolation = RenderingHints.VALUE_INTERPOLATION_BILINEAR; break;
case "nearest-neighbor":
case "nearest":
psOptions.interpolation = RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR; break;
default:
LoggerUtilities.optionWarn(log, "valid value", "interpolation", configOpts.opt("interpolation")); break;
}
}
if (!configOpts.isNull("jobName")) {
psOptions.jobName = configOpts.optString("jobName", null);
}
if (!configOpts.isNull("legacy")) {
psOptions.legacy = configOpts.optBoolean("legacy", false);
}
if (!configOpts.isNull("margins")) {
Margins m = new Margins();
JSONObject subMargins = configOpts.optJSONObject("margins");
if (subMargins != null) {
//each individually
if (!subMargins.isNull("top")) {
try { m.top = subMargins.getDouble("top"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins.top", subMargins.opt("top")); }
}
if (!subMargins.isNull("right")) {
try { m.right = subMargins.getDouble("right"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins.right", subMargins.opt("right")); }
}
if (!subMargins.isNull("bottom")) {
try { m.bottom = subMargins.getDouble("bottom"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins.bottom", subMargins.opt("bottom")); }
}
if (!subMargins.isNull("left")) {
try { m.left = subMargins.getDouble("left"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins.left", subMargins.opt("left")); }
}
} else {
try { m.setAll(configOpts.getDouble("margins")); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "margins", configOpts.opt("margins")); }
}
psOptions.margins = m;
}
if (!configOpts.isNull("orientation")) {
try {
psOptions.orientation = Orientation.valueOf(configOpts.optString("orientation").replaceAll("-", "_").toUpperCase(Locale.ENGLISH));
}
catch(IllegalArgumentException e) {
LoggerUtilities.optionWarn(log, "valid value", "orientation", configOpts.opt("orientation"));
}
}
if (!configOpts.isNull("paperThickness")) {
try { psOptions.paperThickness = configOpts.getDouble("paperThickness"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "paperThickness", configOpts.opt("paperThickness")); }
}
if (!configOpts.isNull("spool")) {
JSONObject spool = configOpts.optJSONObject("spool");
if (spool != null) {
if (!spool.isNull("size")) {
try { psOptions.spoolSize = spool.getInt("size"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "integer", "spool.size", spool.opt("size")); }
}
} else {
LoggerUtilities.optionWarn(log, "JSONObject", "spool", configOpts.opt("spool"));
}
}
if (!configOpts.isNull("printerTray")) {
psOptions.printerTray = configOpts.optString("printerTray", null);
// Guard empty string value; will break pattern matching
if(psOptions.printerTray != null && psOptions.printerTray.trim().equals("")) {
psOptions.printerTray = null;
}
}
if (!configOpts.isNull("rasterize")) {
try { psOptions.rasterize = configOpts.getBoolean("rasterize"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "rasterize", configOpts.opt("rasterize")); }
}
if (!configOpts.isNull("rotation")) {
try { psOptions.rotation = configOpts.getDouble("rotation"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "rotation", configOpts.opt("rotation")); }
}
if (!configOpts.isNull("scaleContent")) {
try { psOptions.scaleContent = configOpts.getBoolean("scaleContent"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "boolean", "scaleContent", configOpts.opt("scaleContent")); }
}
if (!configOpts.isNull("size")) {
Size s = new Size();
JSONObject subSize = configOpts.optJSONObject("size");
if (subSize != null) {
if (!subSize.isNull("width")) {
try { s.width = subSize.getDouble("width"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "size.width", subSize.opt("width")); }
}
if (!subSize.isNull("height")) {
try { s.height = subSize.getDouble("height"); }
catch(JSONException e) { LoggerUtilities.optionWarn(log, "double", "size.height", subSize.opt("height")); }
}
if (s.height <= 0 && s.width <= 0) {
log.warn("Page size has been set without dimensions, using default");
} else {
psOptions.size = s;
}
} else {
LoggerUtilities.optionWarn(log, "JSONObject", "size", configOpts.opt("size"));
}
}
//grab any useful service defaults
PrinterResolution defaultRes = null;
if (output.isSetService()) {
defaultRes = output.getNativePrinter().getResolution().value();
if (defaultRes == null) {
//printer has no default resolution set, see if it is possible to pull anything
List<PrinterResolution> rSupport = output.getNativePrinter().getResolutions();
if (rSupport.size() > 0) {
defaultRes = rSupport.get(0);
log.warn("Default resolution for {} is missing, using fallback: {}", output.getNativePrinter().getName(), defaultRes);
} else {
log.warn("Default resolution for {} is missing, no fallback available.", output.getNativePrinter().getName());
}
}
}
if (defaultRes != null) {
//convert dphi to unit-dependant density ourselves (to keep as double type)
defOptions.density = (double)defaultRes.getFeedResolution(1) / psOptions.getUnits().getDPIUnits();
} else {
try { defOptions.density = configOpts.getDouble("fallbackDensity"); }
catch(JSONException e) {
LoggerUtilities.optionWarn(log, "double", "fallbackDensity", configOpts.opt("fallbackDensity"));
//manually convert default dphi to a density value based on units
defOptions.density = 60000d / psOptions.getUnits().getDPIUnits();
}
}
if ((psOptions.isRasterize() || format == PrintingUtilities.Format.IMAGE) && psOptions.getDensity() <= 1) {
psOptions.density = defOptions.density;
psOptions.crossDensity = defOptions.density;
}
if (output.isSetService()) {
try {
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(output.getPrintService());
PageFormat page = job.getPageFormat(null);
defOptions.pageSize = new Size(page.getWidth(), page.getHeight());
}
catch(PrinterException e) {
log.warn("Unable to find the default paper size");
}
}
}
public Raw getRawOptions() {
return rawOptions;
}
public Pixel getPixelOptions() {
return psOptions;
}
public Default getDefaultOptions() { return defOptions; }
// Option groups //
/** Raw printing options */
public class Raw {
private boolean forceRaw = false; //Alternate printing for linux systems
private String destEncoding = null; //Text encoding / charset
private String srcEncoding = null; //Conversion text encoding
private String spoolEnd = null; //End of document character(s)
private int spoolSize = 1; //Pages per spool
private int copies = 1; //Job copies
private String jobName = null; //Job name
private boolean retainTemp = false; //Retain any temporary files
public boolean isForceRaw() {
return forceRaw;
}
public String getDestEncoding() {
return destEncoding;
}
public String getSrcEncoding() {
return srcEncoding;
}
public String getSpoolEnd() {
return spoolEnd;
}
public int getSpoolSize() {
return spoolSize;
}
public int getCopies() {
return copies;
}
public boolean isRetainTemp() { return retainTemp; }
public String getJobName(String defaultVal) {
return jobName == null || jobName.isEmpty()? defaultVal:jobName;
}
}
/** Pixel printing options */
public class Pixel {
private Bounds bounds = null; //Bounding box rectangle
private ColorType colorType = ColorType.COLOR; //Color / black&white
private int copies = 1; //Job copies
private double crossDensity = 0; //Cross feed density
private double density = 0; //Pixel density (DPI or DPMM), feed density if crossDensity is defined
private Object dithering = RenderingHints.VALUE_DITHER_DEFAULT; //Image dithering
private Sides duplex = Sides.ONE_SIDED; //Multi-siding
private Object interpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC; //Image interpolation
private String jobName = null; //Job name
private boolean legacy = false; //Legacy printing
private Margins margins = new Margins(); //Page margins
private Orientation orientation = null; //Page orientation
private double paperThickness = -1; //Paper thickness
private int spoolSize = 0; //Pages before sending to printer
private String printerTray = null; //Printer tray to use
private boolean rasterize = true; //Whether documents are rasterized before printing
private double rotation = 0; //Image rotation
private boolean scaleContent = true; //Adjust paper size for best image fit
private Size size = null; //Paper size
private Unit units = Unit.INCH; //Units for density, margins, size
public Bounds getBounds() {
return bounds;
}
public ColorType getColorType() {
return colorType;
}
public int getCopies() {
return copies;
}
public double getCrossDensity() {
return crossDensity;
}
public double getDensity() {
return density;
}
public Object getDithering() {
return dithering;
}
public Sides getDuplex() {
return duplex;
}
public Object getInterpolation() {
return interpolation;
}
public String getJobName(String defaultVal) {
return jobName == null || jobName.isEmpty()? defaultVal:jobName;
}
public boolean isLegacy() {
return legacy;
}
public Margins getMargins() {
return margins;
}
public Orientation getOrientation() {
return orientation;
}
public double getPaperThickness() {
return paperThickness;
}
public int getSpoolSize() {
return spoolSize;
}
public String getPrinterTray() {
return printerTray;
}
public boolean isRasterize() {
return rasterize;
}
public double getRotation() {
return rotation;
}
public boolean isScaleContent() {
return scaleContent;
}
public Size getSize() {
return size;
}
public Unit getUnits() {
return units;
}
}
/** PrintService Defaults **/
public class Default {
private double density;
private Size pageSize;
public double getDensity() {
return density;
}
public Size getPageSize() {
return pageSize;
}
}
// Sub options //
/** Pixel page size options */
public class Size {
private double width = -1; //Page width
private double height = -1; //Page height
public Size() {}
public Size(double width, double height) {
this.width = width;
this.height = height;
}
public double getWidth() {
return width;
}
public double getHeight() {
return height;
}
}
/** Pixel page margins options */
public class Margins {
private double top = 0; //Top page margin
private double right = 0; //Right page margin
private double bottom = 0; //Bottom page margin
private double left = 0; //Left page margin
private void setAll(double margin) {
top = margin;
right = margin;
bottom = margin;
left = margin;
}
public double top() {
return top;
}
public double right() {
return right;
}
public double bottom() {
return bottom;
}
public double left() {
return left;
}
}
/* Bounding box generic rectangle */
public class Bounds {
private double x;
private double y;
private double width;
private double height;
public Bounds(double x, double y, double width, double height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public double getX() {
return x;
}
public double getY() {
return y;
}
public double getWidth() {
return width;
}
public double getHeight() {
return height;
}
}
/** Pixel dimension values */
public enum Unit {
INCH(ResolutionSyntax.DPI, 1.0f, 1.0f, Size2DSyntax.INCH), //1in = 1in
CM(ResolutionSyntax.DPCM, .3937f, 2.54f, 10000), //1cm = .3937in ; 1in = 2.54cm
MM(ResolutionSyntax.DPCM * 10, .03937f, 25.4f, Size2DSyntax.MM); //1mm = .03937in ; 1in = 25.4mm
private final float fromInch;
private final float toInch; //multiplicand to convert to inches
private final int resSyntax;
private final int µm;
Unit(int resSyntax, float toIN, float fromIN, int µm) {
toInch = toIN;
fromInch = fromIN;
this.resSyntax = resSyntax;
this.µm = µm;
}
public float toInches() {
return toInch;
}
public float as1Inch() {
return fromInch;
}
public int getDPIUnits() {
return resSyntax;
}
public int getMediaSizeUnits() {
return µm;
}
}
/** Pixel page orientation option */
public enum Orientation {
PORTRAIT(OrientationRequested.PORTRAIT, PageFormat.PORTRAIT, 0),
REVERSE_PORTRAIT(OrientationRequested.PORTRAIT, PageFormat.PORTRAIT, 180),
LANDSCAPE(OrientationRequested.LANDSCAPE, PageFormat.LANDSCAPE, 270),
REVERSE_LANDSCAPE(OrientationRequested.REVERSE_LANDSCAPE, PageFormat.REVERSE_LANDSCAPE, 90);
private final OrientationRequested orientationRequested;
private final int orientationFormat;
private final int degreesRot;
Orientation(OrientationRequested orientationRequested, int orientationFormat, int degreesRot) {
this.orientationRequested = orientationRequested;
this.orientationFormat = orientationFormat;
this.degreesRot = degreesRot;
}
public OrientationRequested getAsOrientRequested() {
return orientationRequested;
}
public int getAsOrientFormat() {
return orientationFormat;
}
public int getDegreesRot() {
return degreesRot;
}
}
/** Pixel page color option */
public enum ColorType {
COLOR(Chromaticity.COLOR),
GREYSCALE(Chromaticity.MONOCHROME),
GRAYSCALE(Chromaticity.MONOCHROME),
BLACKWHITE(Chromaticity.MONOCHROME),
DEFAULT(null);
private final Chromaticity chromatic;
ColorType(Chromaticity chromatic) {
this.chromatic = chromatic;
}
public Chromaticity getAsChromaticity() {
return chromatic;
}
}
}

View File

@@ -0,0 +1,92 @@
package qz.printer;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import qz.printer.info.NativePrinter;
import qz.utils.FileUtilities;
import javax.print.PrintService;
import javax.print.attribute.standard.Media;
import java.io.File;
import java.nio.file.Paths;
public class PrintOutput {
private NativePrinter printer = null;
private File file = null;
private String host = null;
private int port = -1;
public PrintOutput(JSONObject configPrinter) throws JSONException, IllegalArgumentException {
if (configPrinter == null) { return; }
if (configPrinter.has("name")) {
printer = PrintServiceMatcher.matchPrinter(configPrinter.getString("name"));
if (printer == null) {
throw new IllegalArgumentException("Cannot find printer with name \"" + configPrinter.getString("name") + "\"");
}
}
if (configPrinter.has("file")) {
String filename = configPrinter.getString("file");
if (!FileUtilities.isGoodExtension(Paths.get(filename))) {
throw new IllegalArgumentException("Writing to file \"" + filename + "\" is denied for security reasons. (Prohibited file extension)");
} else if (FileUtilities.isBadPath(filename)) {
throw new IllegalArgumentException("Writing to file \"" + filename + "\" is denied for security reasons. (Prohibited directory name)");
} else {
file = new File(filename);
}
}
if (configPrinter.has("host")) {
host = configPrinter.getString("host");
port = configPrinter.optInt("port", 9100); // default to port 9100 (HP/JetDirect standard) if not provided
}
//at least one method must be set for printing
if (!isSetService() && !isSetFile() && !isSetHost()) {
throw new IllegalArgumentException("No printer output has been specified");
}
}
public boolean isSetService() {
return printer != null && printer.getPrintService() != null && !printer.getPrintService().isNull();
}
public PrintService getPrintService() {
return printer.getPrintService().value();
}
public NativePrinter getNativePrinter() {
return printer;
}
public boolean isSetFile() {
return file != null;
}
public File getFile() {
return file;
}
public boolean isSetHost() {
return host != null;
}
public String getHost() {
return host;
}
public int getPort() {
return port;
}
public Media[] getSupportedMedia() {
return (Media[])getPrintService().getSupportedAttributeValues(Media.class, null, null);
}
}

View File

@@ -0,0 +1,242 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.printer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import qz.printer.info.CachedPrintServiceLookup;
import qz.printer.info.NativePrinter;
import qz.printer.info.NativePrinterMap;
import qz.utils.SystemUtilities;
import javax.print.PrintService;
import javax.print.PrintServiceLookup;
import javax.print.attribute.ResolutionSyntax;
import javax.print.attribute.standard.*;
import java.util.*;
public class PrintServiceMatcher {
private static final Logger log = LogManager.getLogger(PrintServiceMatcher.class);
// PrintService is slow in CUPS, use a cache instead per JDK-7001133
// TODO: Include JDK version test for caching when JDK-7001133 is fixed upstream
private static final boolean useCache = SystemUtilities.isUnix();
public static NativePrinterMap getNativePrinterList(boolean silent, boolean withAttributes) {
NativePrinterMap printers = NativePrinterMap.getInstance();
printers.putAll(true, lookupPrintServices());
if (withAttributes) { printers.values().forEach(NativePrinter::getDriverAttributes); }
if (!silent) { log.debug("Found {} printers", printers.size()); }
return printers;
}
private static PrintService[] lookupPrintServices() {
return useCache ? CachedPrintServiceLookup.lookupPrintServices() :
PrintServiceLookup.lookupPrintServices(null, null);
}
private static PrintService lookupDefaultPrintService() {
return useCache ? CachedPrintServiceLookup.lookupDefaultPrintService() :
PrintServiceLookup.lookupDefaultPrintService();
}
public static NativePrinterMap getNativePrinterList(boolean silent) {
return getNativePrinterList(silent, false);
}
public static NativePrinterMap getNativePrinterList() {
return getNativePrinterList(false);
}
public static NativePrinter getDefaultPrinter() {
PrintService defaultService = lookupDefaultPrintService();
if(defaultService == null) {
return null;
}
NativePrinterMap printers = NativePrinterMap.getInstance();
if (!printers.contains(defaultService)) {
printers.putAll(false, defaultService);
}
return printers.get(defaultService);
}
public static String findPrinterName(String query) throws JSONException {
NativePrinter printer = PrintServiceMatcher.matchPrinter(query);
if (printer != null) {
return printer.getPrintService().value().getName();
} else {
return null;
}
}
/**
* Finds {@code PrintService} by looking at any matches to {@code printerSearch}.
*
* @param printerSearch Search query to compare against service names.
*/
public static NativePrinter matchPrinter(String printerSearch, boolean silent) {
NativePrinter exact = null;
NativePrinter begins = null;
NativePrinter partial = null;
if (!silent) { log.debug("Searching for PrintService matching {}", printerSearch); }
// Fix for https://github.com/qzind/tray/issues/931
// This is more than an optimization, removal will lead to a regression
NativePrinter defaultPrinter = getDefaultPrinter();
if (defaultPrinter != null && printerSearch.equals(defaultPrinter.getName())) {
if (!silent) { log.debug("Matched default printer, skipping further search"); }
return defaultPrinter;
}
printerSearch = printerSearch.toLowerCase(Locale.ENGLISH);
// Search services for matches
for(NativePrinter printer : getNativePrinterList(silent).values()) {
if (printer.getName() == null) {
continue;
}
String printerName = printer.getName().toLowerCase(Locale.ENGLISH);
if (printerName.equals(printerSearch)) {
exact = printer;
break;
}
if (printerName.startsWith(printerSearch)) {
begins = printer;
continue;
}
if (printerName.contains(printerSearch)) {
partial = printer;
continue;
}
if (SystemUtilities.isMac()) {
// 1.9 compat: fallback for old style names
PrinterName name = printer.getLegacyName();
if (name == null || name.getValue() == null) { continue; }
printerName = name.getValue().toLowerCase(Locale.ENGLISH);
if (printerName.equals(printerSearch)) {
exact = printer;
continue;
}
if (printerName.startsWith(printerSearch)) {
begins = printer;
continue;
}
if (printerName.contains(printerSearch)) {
partial = printer;
}
}
}
// Return closest match
NativePrinter use = null;
if (exact != null) {
use = exact;
} else if (begins != null) {
use = begins;
} else if (partial != null) {
use = partial;
}
if (use != null) {
if(!silent) log.debug("Found match: {}", use.getPrintService().value().getName());
} else {
log.warn("Printer not found: {}", printerSearch);
}
return use;
}
public static NativePrinter matchPrinter(String printerSearch) {
return matchPrinter(printerSearch, false);
}
public static JSONArray getPrintersJSON(boolean includeDetails) throws JSONException {
JSONArray list = new JSONArray();
PrintService defaultService = lookupDefaultPrintService();
boolean mediaTrayCrawled = false;
for(NativePrinter printer : getNativePrinterList().values()) {
PrintService ps = printer.getPrintService().value();
JSONObject jsonService = new JSONObject();
jsonService.put("name", ps.getName());
if (includeDetails) {
jsonService.put("driver", printer.getDriver().value());
jsonService.put("connection", printer.getConnection());
jsonService.put("default", ps == defaultService);
if (!mediaTrayCrawled) {
log.info("Gathering printer MediaTray information...");
mediaTrayCrawled = true;
}
HashSet<String> uniqueSizes = new HashSet<>(); // prevents duplicates
JSONArray trays = new JSONArray();
JSONArray sizes = new JSONArray();
for(Media m : (Media[])ps.getSupportedAttributeValues(Media.class, null, null)) {
if (m instanceof MediaTray) { trays.put(m.toString()); }
if (m instanceof MediaSizeName) {
if(uniqueSizes.add(m.toString())) {
MediaSize mediaSize = MediaSize.getMediaSizeForName((MediaSizeName)m);
if(mediaSize == null) {
continue;
}
JSONObject size = new JSONObject();
size.put("name", m.toString());
JSONObject in = new JSONObject();
in.put("width", mediaSize.getX(MediaPrintableArea.INCH));
in.put("height", mediaSize.getY(MediaPrintableArea.INCH));
size.put("in", in);
JSONObject mm = new JSONObject();
mm.put("width", mediaSize.getX(MediaPrintableArea.MM));
mm.put("height", mediaSize.getY(MediaPrintableArea.MM));
size.put("mm", mm);
sizes.put(size);
}
}
}
if(trays.length() > 0) {
jsonService.put("trays", trays);
}
if(sizes.length() > 0) {
jsonService.put("sizes", sizes);
}
PrinterResolution res = printer.getResolution().value();
int density = -1; if (res != null) { density = res.getFeedResolution(ResolutionSyntax.DPI); }
jsonService.put("density", density);
}
list.put(jsonService);
}
return list;
}
}

View File

@@ -0,0 +1,97 @@
package qz.printer.action;
import org.apache.commons.codec.binary.Base64InputStream;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.printer.PrintOptions;
import qz.printer.PrintOutput;
import qz.utils.PrintingUtilities;
import javax.print.DocFlavor;
import javax.print.DocPrintJob;
import javax.print.PrintException;
import javax.print.SimpleDoc;
import javax.print.attribute.HashPrintRequestAttributeSet;
import javax.print.attribute.PrintRequestAttributeSet;
import javax.print.attribute.standard.JobName;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Locale;
public class PrintDirect extends PrintRaw {
private static final Logger log = LogManager.getLogger(PrintDirect.class);
private ArrayList<String> prints = new ArrayList<>();
private ArrayList<PrintingUtilities.Flavor> flavors = new ArrayList<>();
@Override
public PrintingUtilities.Format getFormat() {
return PrintingUtilities.Format.DIRECT;
}
@Override
public void parseData(JSONArray printData, PrintOptions options) throws JSONException, UnsupportedOperationException {
for(int i = 0; i < printData.length(); i++) {
JSONObject data = printData.optJSONObject(i);
if (data == null) { continue; }
prints.add(data.getString("data"));
flavors.add(PrintingUtilities.Flavor.parse(data, PrintingUtilities.Flavor.PLAIN));
}
}
@Override
public void print(PrintOutput output, PrintOptions options) throws PrintException {
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
attributes.add(new JobName(options.getRawOptions().getJobName(Constants.RAW_PRINT), Locale.getDefault()));
for(int i = 0; i < prints.size(); i++) {
DocPrintJob printJob = output.getPrintService().createPrintJob();
InputStream stream = null;
try {
switch(flavors.get(i)) {
case BASE64:
stream = new Base64InputStream(new ByteArrayInputStream(prints.get(i).getBytes("UTF-8")));
break;
case FILE:
stream = new DataInputStream(new URL(prints.get(i)).openStream());
break;
case PLAIN:
default:
stream = new ByteArrayInputStream(prints.get(i).getBytes("UTF-8"));
break;
}
SimpleDoc doc = new SimpleDoc(stream, DocFlavor.INPUT_STREAM.AUTOSENSE, null);
waitForPrint(printJob, doc, attributes);
}
catch(IOException e) {
throw new PrintException(e);
}
finally {
if (stream != null) {
try { stream.close(); } catch(Exception ignore) {}
}
}
}
}
@Override
public void cleanup() {
prints.clear();
flavors.clear();
}
}

View File

@@ -0,0 +1,414 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.printer.action;
import com.sun.javafx.print.PrintHelper;
import com.sun.javafx.print.Units;
import javafx.print.*;
import org.apache.commons.io.IOUtils;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.printer.PrintOptions;
import qz.printer.PrintOutput;
import qz.printer.action.html.WebApp;
import qz.printer.action.html.WebAppModel;
import qz.utils.PrintingUtilities;
import javax.print.attribute.PrintRequestAttributeSet;
import javax.print.attribute.standard.Copies;
import javax.print.attribute.standard.CopiesSupported;
import javax.print.attribute.standard.Sides;
import javax.swing.*;
import java.awt.*;
import java.awt.print.PageFormat;
import java.awt.print.PrinterException;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class PrintHTML extends PrintImage implements PrintProcessor {
private static final Logger log = LogManager.getLogger(PrintHTML.class);
private List<WebAppModel> models;
private JLabel legacyLabel = null;
public PrintHTML() {
super();
models = new ArrayList<>();
}
@Override
public PrintingUtilities.Format getFormat() {
return PrintingUtilities.Format.HTML;
}
@Override
public void parseData(JSONArray printData, PrintOptions options) throws JSONException, UnsupportedOperationException {
try {
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
if (!pxlOpts.isLegacy()) {
WebApp.initialize();
}
for(int i = 0; i < printData.length(); i++) {
JSONObject data = printData.getJSONObject(i);
PrintingUtilities.Flavor flavor = PrintingUtilities.Flavor.parse(data, PrintingUtilities.Flavor.FILE);
String source;
switch(flavor) {
case FILE:
case PLAIN:
// We'll toggle between 'plain' and 'file' when we construct WebAppModel
source = data.getString("data");
break;
default:
source = new String(flavor.read(data.getString("data")), StandardCharsets.UTF_8);
}
double pageZoom = (pxlOpts.getDensity() * pxlOpts.getUnits().as1Inch()) / 72.0;
if (pageZoom <= 1) { pageZoom = 1; }
double pageWidth = 0;
double pageHeight = 0;
double convertFactor = (72.0 / pxlOpts.getUnits().as1Inch());
boolean renderFromHeight = Arrays.asList(PrintOptions.Orientation.LANDSCAPE,
PrintOptions.Orientation.REVERSE_LANDSCAPE).contains(pxlOpts.getOrientation());
if (pxlOpts.getSize() != null) {
if (!renderFromHeight) {
pageWidth = pxlOpts.getSize().getWidth() * convertFactor;
} else {
pageWidth = pxlOpts.getSize().getHeight() * convertFactor;
}
} else if (options.getDefaultOptions().getPageSize() != null) {
if (!renderFromHeight) {
pageWidth = options.getDefaultOptions().getPageSize().getWidth();
} else {
pageWidth = options.getDefaultOptions().getPageSize().getHeight();
}
}
if (pxlOpts.getMargins() != null) {
PrintOptions.Margins margins = pxlOpts.getMargins();
if (!renderFromHeight || pxlOpts.isRasterize()) {
pageWidth -= (margins.left() + margins.right()) * convertFactor;
} else {
pageWidth -= (margins.top() + margins.bottom()) * convertFactor; //due to vector margin matching
}
}
if (!data.isNull("options")) {
JSONObject dataOpt = data.getJSONObject("options");
if (!dataOpt.isNull("pageWidth") && dataOpt.optDouble("pageWidth") > 0) {
pageWidth = dataOpt.optDouble("pageWidth") * convertFactor;
}
if (!dataOpt.isNull("pageHeight") && dataOpt.optDouble("pageHeight") > 0) {
pageHeight = dataOpt.optDouble("pageHeight") * convertFactor;
}
}
models.add(new WebAppModel(source, (flavor != PrintingUtilities.Flavor.FILE), pageWidth, pageHeight, pxlOpts.isScaleContent(), pageZoom));
}
log.debug("Parsed {} html records", models.size());
}
catch(IOException e) {
throw new UnsupportedOperationException("Unable to start JavaFX service", e);
}
catch(NoClassDefFoundError e) {
throw new UnsupportedOperationException("JavaFX libraries not found", e);
}
}
@Override
public void print(PrintOutput output, PrintOptions options) throws PrinterException {
if (options.getPixelOptions().isLegacy()) {
printLegacy(output, options);
} else if (options.getPixelOptions().isRasterize()) {
//grab a snapshot of the pages for PrintImage instead of printing directly
for(WebAppModel model : models) {
try { images.add(WebApp.raster(model)); }
catch(Throwable t) {
if (model.getZoom() > 1 && t instanceof IllegalArgumentException) {
//probably a unrecognized image loader error, try at default zoom
try {
log.warn("Capture failed with increased zoom, attempting with default value");
model.setZoom(1);
images.add(WebApp.raster(model));
}
catch(Throwable tt) {
throw new PrinterException(tt.getMessage());
}
} else {
throw new PrinterException(t.getMessage());
}
}
}
super.print(output, options);
} else {
Printer fxPrinter = null;
for(Printer p : Printer.getAllPrinters()) {
if (p.getName().equals(output.getPrintService().getName())) {
fxPrinter = p;
break;
}
}
if (fxPrinter == null) {
throw new PrinterException("Cannot find printer under the JavaFX libraries");
}
PrinterJob job = PrinterJob.createPrinterJob(fxPrinter);
// apply option settings
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
JobSettings settings = job.getJobSettings();
settings.setJobName(pxlOpts.getJobName(Constants.HTML_PRINT));
settings.setPrintQuality(PrintQuality.HIGH);
// If colortype is default, leave printColor blank. The system's printer settings will be used instead.
if (pxlOpts.getColorType() != PrintOptions.ColorType.DEFAULT) {
settings.setPrintColor(getColor(pxlOpts));
}
if (pxlOpts.getDuplex() == Sides.DUPLEX || pxlOpts.getDuplex() == Sides.TWO_SIDED_LONG_EDGE) {
settings.setPrintSides(PrintSides.DUPLEX);
}
if (pxlOpts.getDuplex() == Sides.TUMBLE || pxlOpts.getDuplex() == Sides.TWO_SIDED_SHORT_EDGE) {
settings.setPrintSides(PrintSides.TUMBLE);
}
if (pxlOpts.getPrinterTray() != null) {
PaperSource tray = findFXTray(fxPrinter.getPrinterAttributes().getSupportedPaperSources(), pxlOpts.getPrinterTray());
if (tray != null) {
settings.setPaperSource(tray);
}
}
if (pxlOpts.getDensity() > 0) {
settings.setPrintResolution(PrintHelper.createPrintResolution((int)pxlOpts.getDensity(), (int)pxlOpts.getDensity()));
}
Paper paper;
if (pxlOpts.getSize() != null && pxlOpts.getSize().getWidth() > 0 && pxlOpts.getSize().getHeight() > 0) {
double convert = 1;
Units units = getUnits(pxlOpts);
if (units == null) {
convert = 10; //need to adjust from cm to mm only for DPCM sizes
units = Units.MM;
}
paper = PrintHelper.createPaper("Custom", pxlOpts.getSize().getWidth() * convert, pxlOpts.getSize().getHeight() * convert, units);
} else {
PrintOptions.Size paperSize = options.getDefaultOptions().getPageSize();
paper = PrintHelper.createPaper("Default", paperSize.getWidth(), paperSize.getHeight(), Units.POINT);
}
PageOrientation orient = fxPrinter.getPrinterAttributes().getDefaultPageOrientation();
if (pxlOpts.getOrientation() != null) {
orient = getOrientation(pxlOpts);
}
try {
PageLayout layout;
PrintOptions.Margins m = pxlOpts.getMargins();
if (m != null) {
//force access to the page layout constructor as the adjusted margins on small sizes are wildly inaccurate
Constructor<PageLayout> plCon = PageLayout.class.getDeclaredConstructor(Paper.class, PageOrientation.class, double.class, double.class, double.class, double.class);
plCon.setAccessible(true);
//margins defined as pnt (1/72nds)
double asPnt = pxlOpts.getUnits().toInches() * 72;
if (orient == PageOrientation.PORTRAIT || orient == PageOrientation.REVERSE_PORTRAIT) {
layout = plCon.newInstance(paper, orient, m.left() * asPnt, m.right() * asPnt, m.top() * asPnt, m.bottom() * asPnt);
} else {
//rotate margins to match raster prints
layout = plCon.newInstance(paper, orient, m.top() * asPnt, m.bottom() * asPnt, m.right() * asPnt, m.left() * asPnt);
}
} else {
//if margins are not provided, use default paper margins
PageLayout valid = fxPrinter.getDefaultPageLayout();
layout = fxPrinter.createPageLayout(paper, orient, valid.getLeftMargin(), valid.getRightMargin(), valid.getTopMargin(), valid.getBottomMargin());
}
//force our layout as the default to avoid default-margin exceptions on small paper sizes
Field field = fxPrinter.getClass().getDeclaredField("defPageLayout");
field.setAccessible(true);
field.set(fxPrinter, layout);
settings.setPageLayout(layout);
}
catch(Exception e) {
log.error("Failed to set custom layout", e);
}
settings.setCopies(pxlOpts.getCopies());
log.trace("{}", settings.toString());
//javaFX lies about this value, so pull from original print service
CopiesSupported cSupport = (CopiesSupported)output.getPrintService()
.getSupportedAttributeValues(Copies.class, output.getPrintService().getSupportedDocFlavors()[0], null);
try {
if (cSupport != null && cSupport.contains(pxlOpts.getCopies())) {
for(WebAppModel model : models) {
WebApp.print(job, model);
}
} else {
settings.setCopies(1); //manually handle copies if they are not supported
for(int i = 0; i < pxlOpts.getCopies(); i++) {
for(WebAppModel model : models) {
WebApp.print(job, model);
}
}
}
}
catch(Throwable t) {
job.cancelJob();
throw new PrinterException(t.getMessage());
}
//send pending prints
job.endJob();
}
}
private void printLegacy(PrintOutput output, PrintOptions options) throws PrinterException {
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
java.awt.print.PrinterJob job = java.awt.print.PrinterJob.getPrinterJob();
job.setPrintService(output.getPrintService());
PageFormat page = job.getPageFormat(null);
PrintRequestAttributeSet attributes = applyDefaultSettings(pxlOpts, page, output.getSupportedMedia());
//setup swing ui
JFrame legacyFrame = new JFrame(pxlOpts.getJobName(Constants.HTML_PRINT));
legacyFrame.setUndecorated(true);
legacyFrame.setLayout(new FlowLayout());
legacyFrame.setExtendedState(Frame.ICONIFIED);
legacyLabel = new JLabel();
legacyLabel.setOpaque(true);
legacyLabel.setBackground(Color.WHITE);
legacyLabel.setBorder(null);
legacyLabel.setDoubleBuffered(false);
legacyFrame.add(legacyLabel);
try {
for(WebAppModel model : models) {
if (model.isPlainText()) {
legacyLabel.setText(cleanHtmlContent(model.getSource()));
} else {
try(InputStream fis = new URL(model.getSource()).openStream()) {
String webPage = cleanHtmlContent(IOUtils.toString(fis, "UTF-8"));
legacyLabel.setText(webPage);
}
}
legacyFrame.pack();
legacyFrame.setVisible(true);
job.setPrintable(this);
printCopies(output, pxlOpts, job, attributes);
}
}
catch(Exception e) {
throw new PrinterException(e.getMessage());
}
finally {
legacyFrame.dispose();
}
}
private String cleanHtmlContent(String html) {
return html.replaceAll("^[\\s\\S]*<(HTML|html)\\b.*?>", "<html>");
}
@Override
public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) throws PrinterException {
if (legacyLabel == null) {
return super.print(graphics, pageFormat, pageIndex);
} else {
if (graphics == null) { throw new PrinterException("No graphics specified"); }
if (pageFormat == null) { throw new PrinterException("No page format specified"); }
if (pageIndex + 1 > models.size()) {
return NO_SUCH_PAGE;
}
log.trace("Requested page {} for printing", pageIndex);
Graphics2D graphics2D = (Graphics2D)graphics;
graphics2D.setRenderingHints(buildRenderingHints(dithering, interpolation));
graphics2D.translate(pageFormat.getImageableX(), pageFormat.getImageableY());
graphics2D.scale(pageFormat.getImageableWidth() / pageFormat.getWidth(), pageFormat.getImageableHeight() / pageFormat.getHeight());
legacyLabel.paint(graphics2D);
return PAGE_EXISTS;
}
}
@Override
public void cleanup() {
super.cleanup();
models.clear();
legacyLabel = null;
}
public static Units getUnits(PrintOptions.Pixel opts) {
switch(opts.getUnits()) {
case INCH:
return Units.INCH;
case MM:
return Units.MM;
default:
return null;
}
}
public static PageOrientation getOrientation(PrintOptions.Pixel opts) {
switch(opts.getOrientation()) {
case LANDSCAPE:
return PageOrientation.LANDSCAPE;
case REVERSE_LANDSCAPE:
return PageOrientation.REVERSE_LANDSCAPE;
case REVERSE_PORTRAIT:
return PageOrientation.REVERSE_PORTRAIT;
default:
return PageOrientation.PORTRAIT;
}
}
public static PrintColor getColor(PrintOptions.Pixel opts) {
switch(opts.getColorType()) {
case COLOR:
return PrintColor.COLOR;
default:
return PrintColor.MONOCHROME;
}
}
}

View File

@@ -0,0 +1,341 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*
*/
package qz.printer.action;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.Constants;
import qz.printer.PrintOptions;
import qz.printer.PrintOutput;
import qz.utils.ConnectionUtilities;
import qz.utils.PrintingUtilities;
import qz.utils.SystemUtilities;
import javax.imageio.IIOException;
import javax.imageio.ImageIO;
import javax.print.attribute.PrintRequestAttributeSet;
import javax.print.attribute.ResolutionSyntax;
import javax.print.attribute.standard.OrientationRequested;
import javax.print.attribute.standard.PrinterResolution;
import java.awt.*;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.awt.print.PageFormat;
import java.awt.print.Printable;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.io.ByteArrayInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* @author Tres Finocchiaro, Anton Mezerny
*/
public class PrintImage extends PrintPixel implements PrintProcessor, Printable {
private static final Logger log = LogManager.getLogger(PrintImage.class);
protected List<BufferedImage> images;
protected double dpiScale = 1;
protected boolean scaleImage = false;
protected Object dithering = RenderingHints.VALUE_DITHER_DEFAULT;
protected Object interpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC;
protected double imageRotation = 0;
protected boolean manualReverse = false;
public PrintImage() {
images = new ArrayList<>();
}
@Override
public PrintingUtilities.Format getFormat() {
return PrintingUtilities.Format.IMAGE;
}
@Override
public void parseData(JSONArray printData, PrintOptions options) throws JSONException, UnsupportedOperationException {
dpiScale = (options.getPixelOptions().getDensity() * options.getPixelOptions().getUnits().as1Inch()) / 72.0;
for(int i = 0; i < printData.length(); i++) {
JSONObject data = printData.getJSONObject(i);
PrintingUtilities.Flavor flavor = PrintingUtilities.Flavor.parse(data, PrintingUtilities.Flavor.FILE);
try {
BufferedImage bi;
switch(flavor) {
case PLAIN:
// There's really no such thing as a 'PLAIN' image, assume it's a URL
case FILE:
bi = ImageIO.read(ConnectionUtilities.getInputStream(data.getString("data"), true));
break;
default:
bi = ImageIO.read(new ByteArrayInputStream(flavor.read(data.getString("data"))));
}
images.add(bi);
}
catch(IIOException e) {
if (e.getCause() != null && e.getCause() instanceof FileNotFoundException) {
throw new UnsupportedOperationException("Image file specified could not be found.", e);
} else {
throw new UnsupportedOperationException(String.format("Cannot parse (%s)%s as an image", flavor, data.getString("data")), e);
}
}
catch(IOException e) {
throw new UnsupportedOperationException(String.format("Cannot parse (%s)%s as an image: %s", flavor, data.getString("data"), e.getLocalizedMessage()), e);
}
}
log.debug("Parsed {} images for printing", images.size());
}
private List<BufferedImage> breakupOverPages(BufferedImage img, PageFormat page, PrintRequestAttributeSet attributes) {
List<BufferedImage> splits = new ArrayList<>();
Rectangle printBounds = new Rectangle(0, 0, (int)page.getImageableWidth(), (int)page.getImageableHeight());
PrinterResolution res = (PrinterResolution)attributes.get(PrinterResolution.class);
float dpi = res.getFeedResolution(1) / (float)ResolutionSyntax.DPI;
float cdpi = res.getCrossFeedResolution(1) / (float)ResolutionSyntax.DPI;
//printing uses 72dpi, convert so we can check split size correctly
int useWidth = (int)((img.getWidth() / cdpi) * 72);
int useHeight = (int)((img.getHeight() / dpi) * 72);
int columnsNeed = (int)Math.ceil(useWidth / page.getImageableWidth());
int rowsNeed = (int)Math.ceil(useHeight / page.getImageableHeight());
if (columnsNeed == 1 && rowsNeed == 1) {
log.trace("Unscaled image does not need spit");
splits.add(img);
} else {
log.trace("Image to be printed across {} pages", columnsNeed * rowsNeed);
//allows us to split the image at the actual dpi instead of 72
float upscale = dpi / 72f;
float c_upscale = cdpi / 72f;
for(int row = 0; row < rowsNeed; row++) {
for(int col = 0; col < columnsNeed; col++) {
Rectangle clip = new Rectangle((col * (int)(printBounds.width * c_upscale)), (row * (int)(printBounds.height * upscale)),
(int)(printBounds.width * c_upscale), (int)(printBounds.height * upscale));
if (clip.x + clip.width > img.getWidth()) { clip.width = img.getWidth() - clip.x; }
if (clip.y + clip.height > img.getHeight()) { clip.height = img.getHeight() - clip.y; }
splits.add(img.getSubimage(clip.x, clip.y, clip.width, clip.height));
}
}
}
return splits;
}
@Override
public void print(PrintOutput output, PrintOptions options) throws PrinterException {
if (images.isEmpty()) {
log.warn("Nothing to print");
return;
}
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(output.getPrintService());
PageFormat page = job.getPageFormat(null);
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
PrintRequestAttributeSet attributes = applyDefaultSettings(pxlOpts, page, output.getSupportedMedia());
scaleImage = pxlOpts.isScaleContent();
dithering = pxlOpts.getDithering();
interpolation = pxlOpts.getInterpolation();
imageRotation = pxlOpts.getRotation();
//reverse fix for OSX
if (SystemUtilities.isMac() && pxlOpts.getOrientation() != null
&& pxlOpts.getOrientation().getAsOrientRequested() == OrientationRequested.REVERSE_LANDSCAPE) {
imageRotation += 180;
manualReverse = true;
}
if (!scaleImage) {
//breakup large images to print across pages as needed
List<BufferedImage> split = new ArrayList<>();
for(BufferedImage bi : images) {
split.addAll(breakupOverPages(bi, page, attributes));
}
images = split;
}
job.setJobName(pxlOpts.getJobName(Constants.IMAGE_PRINT));
job.setPrintable(this, job.validatePage(page));
printCopies(output, pxlOpts, job, attributes);
}
@Override
public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) throws PrinterException {
if (graphics == null) { throw new PrinterException("No graphics specified"); }
if (pageFormat == null) { throw new PrinterException("No page format specified"); }
if (pageIndex + 1 > images.size()) {
return NO_SUCH_PAGE;
}
log.trace("Requested page {} for printing", pageIndex);
if ("sun.print.PeekGraphics".equals(graphics.getClass().getCanonicalName())) {
//java uses class only to query if a page needs printed - save memory/time by short circuiting
return PAGE_EXISTS;
}
//allows pages view to rotate in different orientations
graphics.drawString(" ", 0, 0);
BufferedImage imgToPrint = fixColorModel(images.get(pageIndex));
if (imageRotation % 360 != 0) {
imgToPrint = rotate(imgToPrint, imageRotation);
}
// apply image scaling
double boundW = pageFormat.getImageableWidth();
double boundH = pageFormat.getImageableHeight();
double imgW = imgToPrint.getWidth() / dpiScale;
double imgH = imgToPrint.getHeight() / dpiScale;
if (scaleImage) {
imgToPrint = scale(imgToPrint, pageFormat);
// adjust dimensions to smallest edge, keeping size ratio
if (((float)imgToPrint.getWidth() / (float)imgToPrint.getHeight()) >= (boundW / boundH)) {
imgW = boundW;
imgH = (imgToPrint.getHeight() / (imgToPrint.getWidth() / boundW));
} else {
imgW = (imgToPrint.getWidth() / (imgToPrint.getHeight() / boundH));
imgH = boundH;
}
}
double boundX = pageFormat.getImageableX();
double boundY = pageFormat.getImageableY();
log.debug("Paper area: {},{}:{},{}", (int)boundX, (int)boundY, (int)boundW, (int)boundH);
log.trace("Image size: {},{}", imgW, imgH);
// Now we perform our rendering
Graphics2D graphics2D = (Graphics2D)graphics;
graphics2D.setRenderingHints(buildRenderingHints(dithering, interpolation));
log.trace("{}", graphics2D.getRenderingHints());
log.debug("Memory: {}m/{}m", (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / 1048576, Runtime.getRuntime().maxMemory() / 1048576);
if (!manualReverse) {
graphics2D.drawImage(imgToPrint, (int)boundX, (int)boundY, (int)(boundX + imgW), (int)(boundY + imgH),
0, 0, imgToPrint.getWidth(), imgToPrint.getHeight(), null);
} else {
graphics2D.drawImage(imgToPrint, (int)(boundW + boundX - imgW), (int)(boundH + boundY - imgH), (int)(boundW + boundX), (int)(boundH + boundY),
0, 0, imgToPrint.getWidth(), imgToPrint.getHeight(), null);
}
// Valid page
return PAGE_EXISTS;
}
/**
*
* @param image
* @param pageFormat
* @return
*/
private BufferedImage scale(BufferedImage image, PageFormat pageFormat) {
//scale up to print density (using less of a stretch if image is already larger than page)
double upScale = dpiScale * Math.min((pageFormat.getImageableWidth() / image.getWidth()), (pageFormat.getImageableHeight() / image.getHeight()));
if (upScale > dpiScale) { upScale = dpiScale; } else if (upScale < 1) { upScale = 1; }
if (upScale > 1) {
log.debug("Scaling image up by x{}", upScale);
BufferedImage scaled = new BufferedImage((int)(image.getWidth() * upScale), (int)(image.getHeight() * upScale), BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = scaled.createGraphics();
g2d.setRenderingHints(buildRenderingHints(dithering, interpolation));
g2d.drawImage(image, 0, 0, (int)(image.getWidth() * upScale), (int)(image.getHeight() * upScale), null);
g2d.dispose();
return scaled;
} else {
log.debug("No need to upscale image");
return image;
}
}
/**
* Rotates {@code image} by the specified {@code angle}.
*
* @param image BufferedImage to rotate
* @param angle Rotation angle in degrees
* @return Rotated image data
*/
public static BufferedImage rotate(BufferedImage image, double angle, Object dithering, Object interpolation) {
double rads = Math.toRadians(angle);
double sin = Math.abs(Math.sin(rads)), cos = Math.abs(Math.cos(rads));
int sWidth = image.getWidth(), sHeight = image.getHeight();
int eWidth = (int)Math.floor((sWidth * cos) + (sHeight * sin)), eHeight = (int)Math.floor((sHeight * cos) + (sWidth * sin));
BufferedImage result;
if(!GraphicsEnvironment.isHeadless()) {
GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices()[0].getDefaultConfiguration();
result = gc.createCompatibleImage(eWidth, eHeight, Transparency.TRANSLUCENT);
} else {
result = new BufferedImage(eWidth, eHeight, Transparency.TRANSLUCENT);
}
Graphics2D g2d = result.createGraphics();
g2d.setRenderingHints(buildRenderingHints(dithering, interpolation));
g2d.translate((eWidth - sWidth) / 2, (eHeight - sHeight) / 2);
g2d.rotate(rads, sWidth / 2, sHeight / 2);
if (angle % 90 == 0 || interpolation == RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR) {
g2d.drawRenderedImage(image, null);
} else {
g2d.setPaint(new TexturePaint(image, new Rectangle2D.Float(0, 0, image.getWidth(), image.getHeight())));
g2d.fillRect(0, 0, image.getWidth(), image.getHeight());
}
g2d.dispose();
return result;
}
private BufferedImage rotate(BufferedImage image, double angle) {
return rotate(image, angle, dithering, interpolation);
}
@Override
public void cleanup() {
images.clear();
dpiScale = 1.0;
scaleImage = false;
imageRotation = 0;
dithering = RenderingHints.VALUE_DITHER_DEFAULT;
interpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC;
manualReverse = false;
}
}

View File

@@ -0,0 +1,326 @@
package qz.printer.action;
import com.github.zafarkhaja.semver.Version;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.multipdf.Splitter;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.printing.Scaling;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import qz.common.Constants;
import qz.printer.PrintOptions;
import qz.printer.PrintOutput;
import qz.printer.action.pdf.BookBundle;
import qz.printer.action.pdf.PDFWrapper;
import qz.utils.ConnectionUtilities;
import qz.utils.PrintingUtilities;
import qz.utils.SystemUtilities;
import javax.print.attribute.PrintRequestAttributeSet;
import javax.print.attribute.standard.Media;
import javax.print.attribute.standard.MediaPrintableArea;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.print.PageFormat;
import java.awt.print.Paper;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.io.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class PrintPDF extends PrintPixel implements PrintProcessor {
private static final Logger log = LogManager.getLogger(PrintPDF.class);
private List<PDDocument> originals;
private List<PDDocument> printables;
private Splitter splitter = new Splitter();
private double docWidth = 0;
private double docHeight = 0;
private boolean ignoreTransparency = false;
private boolean altFontRendering = false;
public PrintPDF() {
originals = new ArrayList<>();
printables = new ArrayList<>();
}
@Override
public PrintingUtilities.Format getFormat() {
return PrintingUtilities.Format.PDF;
}
@Override
public void parseData(JSONArray printData, PrintOptions options) throws JSONException, UnsupportedOperationException {
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
double convert = 72.0 / pxlOpts.getUnits().as1Inch();
for(int i = 0; i < printData.length(); i++) {
JSONObject data = printData.getJSONObject(i);
HashSet<Integer> pagesToPrint = new HashSet<>();
if (!data.isNull("options")) {
JSONObject dataOpt = data.getJSONObject("options");
if (!dataOpt.isNull("pageWidth") && dataOpt.optDouble("pageWidth") > 0) {
docWidth = dataOpt.optDouble("pageWidth") * convert;
}
if (!dataOpt.isNull("pageHeight") && dataOpt.optDouble("pageHeight") > 0) {
docHeight = dataOpt.optDouble("pageHeight") * convert;
}
ignoreTransparency = dataOpt.optBoolean("ignoreTransparency", false);
altFontRendering = dataOpt.optBoolean("altFontRendering", false);
if (!dataOpt.isNull("pageRanges")) {
String[] ranges = dataOpt.optString("pageRanges", "").split(",");
for(String range : ranges) {
range = range.trim();
if(range.isEmpty()) {
continue;
}
String[] period = range.split("-");
try {
int start = Integer.parseInt(period[0]);
pagesToPrint.add(start);
if (period.length > 1) {
int end = Integer.parseInt(period[period.length - 1]);
pagesToPrint.addAll(IntStream.rangeClosed(start, end).boxed().collect(Collectors.toSet()));
}
}
catch(NumberFormatException nfe) {
log.warn("Unable to parse page range {}.", range);
}
}
}
}
PrintingUtilities.Flavor flavor = PrintingUtilities.Flavor.parse(data, PrintingUtilities.Flavor.FILE);
try {
PDDocument doc;
switch(flavor) {
case PLAIN:
// There's really no such thing as a 'PLAIN' PDF, assume it's a URL
case FILE:
doc = PDDocument.load(ConnectionUtilities.getInputStream(data.getString("data"), true));
break;
default:
doc = PDDocument.load(new ByteArrayInputStream(flavor.read(data.getString("data"))));
}
if (pxlOpts.getBounds() != null) {
PrintOptions.Bounds bnd = pxlOpts.getBounds();
for(PDPage page : doc.getPages()) {
PDRectangle box = new PDRectangle(
(float)(bnd.getX() * convert),
page.getMediaBox().getUpperRightY() - (float)((bnd.getHeight() + bnd.getY()) * convert),
(float)(bnd.getWidth() * convert),
(float)(bnd.getHeight() * convert));
page.setMediaBox(box);
}
}
if (pagesToPrint.isEmpty()) {
pagesToPrint.addAll(IntStream.rangeClosed(1, doc.getNumberOfPages()).boxed().collect(Collectors.toSet()));
}
originals.add(doc);
List<PDDocument> splitPages = splitter.split(doc);
originals.addAll(splitPages); //ensures non-ranged page will still get closed
for(int pg = 0; pg < splitPages.size(); pg++) {
if (pagesToPrint.contains(pg + 1)) { //ranges are 1-indexed
printables.add(splitPages.get(pg));
}
}
}
catch(FileNotFoundException e) {
throw new UnsupportedOperationException("PDF file specified could not be found.", e);
}
catch(IOException e) {
throw new UnsupportedOperationException(String.format("Cannot parse (%s)%s as a PDF file: %s", flavor, data.getString("data"), e.getLocalizedMessage()), e);
}
}
log.debug("Parsed {} files for printing", printables.size());
}
@Override
public PrintRequestAttributeSet applyDefaultSettings(PrintOptions.Pixel pxlOpts, PageFormat page, Media[] supported) {
if (pxlOpts.getOrientation() != null) {
//page orient does not set properly on pdfs with orientation requested attribute
page.setOrientation(pxlOpts.getOrientation().getAsOrientFormat());
}
return super.applyDefaultSettings(pxlOpts, page, supported);
}
@Override
public void print(PrintOutput output, PrintOptions options) throws PrinterException {
if (printables.isEmpty()) {
log.warn("Nothing to print");
return;
}
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(output.getPrintService());
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
Scaling scale = (pxlOpts.isScaleContent()? Scaling.SCALE_TO_FIT:Scaling.ACTUAL_SIZE);
PrintRequestAttributeSet attributes = applyDefaultSettings(pxlOpts, job.getPageFormat(null), (Media[])output.getPrintService().getSupportedAttributeValues(Media.class, null, null));
// Disable attributes per https://github.com/qzind/tray/issues/174
if (SystemUtilities.isMac() && Constants.JAVA_VERSION.compareWithBuildsTo(Version.valueOf("1.8.0+202")) < 0) {
log.warn("MacOS and Java < 1.8.0u202 cannot use attributes with PDF prints, disabling");
attributes.clear();
}
RenderingHints hints = new RenderingHints(buildRenderingHints(pxlOpts.getDithering(), pxlOpts.getInterpolation()));
double useDensity = pxlOpts.getDensity();
if (!pxlOpts.isRasterize()) {
if (pxlOpts.getDensity() > 0) {
// clear density for vector prints (applied via print attributes instead)
useDensity = 0;
} else if (SystemUtilities.isMac() && Constants.JAVA_VERSION.compareWithBuildsTo(Version.valueOf("1.8.0+121")) < 0) {
log.warn("OSX systems cannot print vector PDF's, forcing raster to prevent crash.");
useDensity = options.getDefaultOptions().getDensity();
}
}
BookBundle bundle = new BookBundle();
for(PDDocument doc : printables) {
PageFormat page = job.getPageFormat(null);
applyDefaultSettings(pxlOpts, page, output.getSupportedMedia());
//trick pdfbox into an alternate doc size if specified
if (docWidth > 0 || docHeight > 0) {
Paper paper = page.getPaper();
if (docWidth <= 0) { docWidth = page.getImageableWidth(); }
if (docHeight <= 0) { docHeight = page.getImageableHeight(); }
paper.setImageableArea(paper.getImageableX(), paper.getImageableY(), docWidth, docHeight);
page.setPaper(paper);
scale = Scaling.SCALE_TO_FIT; //to get custom size we need to force scaling
//pdf uses imageable area from Paper, so this can be safely removed
attributes.remove(MediaPrintableArea.class);
}
for(PDPage pd : doc.getPages()) {
if (pxlOpts.getRotation() % 360 != 0) {
rotatePage(doc, pd, pxlOpts.getRotation());
}
if (pxlOpts.getOrientation() == null) {
PDRectangle bounds = pd.getBBox();
if ((page.getImageableHeight() > page.getImageableWidth() && bounds.getWidth() > bounds.getHeight()) ^ (pd.getRotation() / 90) % 2 == 1) {
log.info("Adjusting orientation to print landscape PDF source");
page.setOrientation(PrintOptions.Orientation.LANDSCAPE.getAsOrientFormat());
}
} else if (pxlOpts.getOrientation() != PrintOptions.Orientation.PORTRAIT) {
//flip imageable area dimensions when in landscape
Paper repap = page.getPaper();
repap.setImageableArea(repap.getImageableX(), repap.getImageableY(), repap.getImageableHeight(), repap.getImageableWidth());
page.setPaper(repap);
//reverse fix for OSX
if (SystemUtilities.isMac() && pxlOpts.getOrientation() == PrintOptions.Orientation.REVERSE_LANDSCAPE) {
pd.setRotation(pd.getRotation() + 180);
}
}
}
PDFWrapper wrapper = new PDFWrapper(doc, scale, false, ignoreTransparency, altFontRendering,
(float)(useDensity * pxlOpts.getUnits().as1Inch()),
false, pxlOpts.getOrientation(), hints);
bundle.append(wrapper, page, doc.getNumberOfPages());
}
if (pxlOpts.getSpoolSize() > 0 && bundle.getNumberOfPages() > pxlOpts.getSpoolSize()) {
int jobNum = 1;
int offset = 0;
while(offset < bundle.getNumberOfPages()) {
job.setJobName(pxlOpts.getJobName(Constants.PDF_PRINT) + "-" + jobNum++);
job.setPageable(bundle.wrapAndPresent(offset, pxlOpts.getSpoolSize()));
printCopies(output, pxlOpts, job, attributes);
offset += pxlOpts.getSpoolSize();
}
} else {
job.setJobName(pxlOpts.getJobName(Constants.PDF_PRINT));
job.setPageable(bundle.wrapAndPresent());
printCopies(output, pxlOpts, job, attributes);
}
}
private void rotatePage(PDDocument doc, PDPage page, double rotation) {
try {
//copy page to object for manipulation
PDFormXObject xobject = new PDFormXObject(doc);
InputStream src = page.getContents();
OutputStream dest = xobject.getStream().createOutputStream();
try { IOUtils.copy(src, dest); }
finally {
IOUtils.closeQuietly(src);
IOUtils.closeQuietly(dest);
}
xobject.setResources(page.getResources());
xobject.setBBox(page.getBBox());
//draw our object at a rotated angle
AffineTransform transform = new AffineTransform();
transform.rotate(Math.toRadians(360 - rotation), xobject.getBBox().getWidth() / 2.0, xobject.getBBox().getHeight() / 2.0);
xobject.setMatrix(transform);
PDPageContentStream stream = new PDPageContentStream(doc, page);
stream.drawForm(xobject);
stream.close();
}
catch(IOException e) {
log.warn("Failed to rotate PDF page for printing");
}
}
@Override
public void cleanup() {
for(PDDocument doc : originals) {
try { doc.close(); } catch(IOException ignore) {}
}
originals.clear();
printables.clear();
docWidth = 0;
docHeight = 0;
ignoreTransparency = false;
altFontRendering = false;
}
}

View File

@@ -0,0 +1,231 @@
package qz.printer.action;
import javafx.print.PaperSource;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.printer.PrintOptions;
import qz.printer.PrintOutput;
import qz.utils.SystemUtilities;
import javax.print.attribute.HashPrintRequestAttributeSet;
import javax.print.attribute.PrintRequestAttributeSet;
import javax.print.attribute.standard.*;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.IndexColorModel;
import java.awt.print.PageFormat;
import java.awt.print.Paper;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.util.List;
import java.util.*;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public abstract class PrintPixel {
private static final Logger log = LogManager.getLogger(PrintPixel.class);
private static final List<Integer> MAC_BAD_IMAGE_TYPES = Arrays.asList(BufferedImage.TYPE_BYTE_BINARY, BufferedImage.TYPE_CUSTOM);
protected PrintRequestAttributeSet applyDefaultSettings(PrintOptions.Pixel pxlOpts, PageFormat page, Media[] supported) {
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
//apply general attributes
// If colortype is default, leave printColor blank. The system's printer settings will be used instead.
if (pxlOpts.getColorType() != PrintOptions.ColorType.DEFAULT) {
attributes.add(pxlOpts.getColorType().getAsChromaticity());
}
attributes.add(pxlOpts.getDuplex());
if (pxlOpts.getOrientation() != null) {
attributes.add(pxlOpts.getOrientation().getAsOrientRequested());
}
if (pxlOpts.getPrinterTray() != null && !pxlOpts.getPrinterTray().isEmpty()) {
Media tray = findMediaTray(supported, pxlOpts.getPrinterTray());
if (tray != null) {
attributes.add(tray);
}
}
//TODO - set paper thickness
// Java prints using inches at 72dpi
final float CONVERT = pxlOpts.getUnits().toInches() * 72f;
log.trace("DPI: [{}x{}]\tCNV: {}", pxlOpts.getDensity(), pxlOpts.getCrossDensity(), CONVERT);
if (pxlOpts.getDensity() > 0) {
double cross = pxlOpts.getCrossDensity();
if (cross == 0) { cross = pxlOpts.getDensity(); }
attributes.add(new PrinterResolution((int)cross, (int)pxlOpts.getDensity(), pxlOpts.getUnits().getDPIUnits()));
}
//apply sizing and margins
Paper paper = page.getPaper();
float pageX = 0f;
float pageY = 0f;
float pageW = (float)page.getWidth() / CONVERT;
float pageH = (float)page.getHeight() / CONVERT;
//page size
if (pxlOpts.getSize() != null && pxlOpts.getSize().getWidth() > 0 && pxlOpts.getSize().getHeight() > 0) {
pageW = (float)pxlOpts.getSize().getWidth();
pageH = (float)pxlOpts.getSize().getHeight();
paper.setSize(pageW * CONVERT, pageH * CONVERT);
}
//margins
if (pxlOpts.getMargins() != null) {
pageX += pxlOpts.getMargins().left();
pageY += pxlOpts.getMargins().top();
pageW -= (pxlOpts.getMargins().right() + pxlOpts.getMargins().left());
pageH -= (pxlOpts.getMargins().bottom() + pxlOpts.getMargins().top());
}
log.trace("Drawable area: {},{}:{},{}", pageX, pageY, pageW, pageH);
if (pageW > 0 && pageH > 0) {
attributes.add(new MediaPrintableArea(pageX, pageY, pageW, pageH, pxlOpts.getUnits().getMediaSizeUnits()));
paper.setImageableArea(pageX * CONVERT, pageY * CONVERT, pageW * CONVERT, pageH * CONVERT);
page.setPaper(paper);
} else {
log.warn("Could not apply custom size, using printer default");
attributes.add(new MediaPrintableArea(0, 0, (float)page.getWidth() / 72f, (float)page.getHeight() / 72f, PrintOptions.Unit.INCH.getMediaSizeUnits()));
}
log.trace("{}", Arrays.toString(attributes.toArray()));
return attributes;
}
protected void printCopies(PrintOutput output, PrintOptions.Pixel pxlOpts, PrinterJob job, PrintRequestAttributeSet attributes) throws PrinterException {
log.info("Starting printing ({} copies)", pxlOpts.getCopies());
PrinterResolution rUsing = (PrinterResolution)attributes.get(PrinterResolution.class);
if (rUsing != null) {
List<PrinterResolution> rSupport = output.getNativePrinter().getResolutions();
if (!rSupport.isEmpty()) {
if (!rSupport.contains(rUsing)) {
log.warn("Not using a supported DPI for printing");
log.debug("Available DPI: {}", ArrayUtils.toString(rSupport));
}
} else {
log.warn("Supported printer densities not found");
}
}
CopiesSupported cSupport = (CopiesSupported)output.getPrintService()
.getSupportedAttributeValues(Copies.class, output.getPrintService().getSupportedDocFlavors()[0], attributes);
if (cSupport != null && cSupport.contains(pxlOpts.getCopies())) {
attributes.add(new Copies(pxlOpts.getCopies()));
job.print(attributes);
} else {
for(int i = 0; i < pxlOpts.getCopies(); i++) {
job.print(attributes);
}
}
}
/**
* FIXME: Temporary fix for OS X 10.10 hard crash.
* See https://github.com/qzind/qz-print/issues/75
*/
protected BufferedImage fixColorModel(BufferedImage imgToPrint) {
if (SystemUtilities.isMac()) {
if (MAC_BAD_IMAGE_TYPES.contains(imgToPrint.getType())) {
BufferedImage sanitizedImage;
ColorModel cm = imgToPrint.getColorModel();
if (cm instanceof IndexColorModel) {
log.info("Image converted to 256 colors for OSX 10.10 Workaround");
sanitizedImage = new BufferedImage(imgToPrint.getWidth(), imgToPrint.getHeight(), BufferedImage.TYPE_BYTE_INDEXED, (IndexColorModel)cm);
} else {
log.info("Image converted to ARGB for OSX 10.10 Workaround");
sanitizedImage = new BufferedImage(imgToPrint.getWidth(), imgToPrint.getHeight(), BufferedImage.TYPE_INT_ARGB);
}
sanitizedImage.createGraphics().drawImage(imgToPrint, 0, 0, null);
imgToPrint = sanitizedImage;
}
}
return imgToPrint;
}
protected static Map<RenderingHints.Key,Object> buildRenderingHints(Object dithering, Object interpolation) {
Map<RenderingHints.Key,Object> rhMap = new HashMap<>();
rhMap.put(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
rhMap.put(RenderingHints.KEY_DITHERING, dithering);
rhMap.put(RenderingHints.KEY_INTERPOLATION, interpolation);
rhMap.put(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
rhMap.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
rhMap.put(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
if (RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR.equals(interpolation)) {
rhMap.put(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF);
rhMap.put(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
} else {
rhMap.put(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
rhMap.put(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
}
return rhMap;
}
protected Media findMediaTray(Media[] supportedMedia, String traySelection) {
HashMap<String,Media> mediaTrays = new HashMap<>();
for(Media m : supportedMedia) {
if (m instanceof MediaTray) {
mediaTrays.put(m.toString(), m);
}
}
String tray = findTray(mediaTrays.keySet(), traySelection);
return mediaTrays.get(tray);
}
protected PaperSource findFXTray(Set<PaperSource> paperSources, String traySelection) {
Map<String,PaperSource> fxTrays = paperSources.stream().collect(Collectors.toMap(PaperSource::getName, Function.identity()));
String tray = findTray(fxTrays.keySet(), traySelection);
return fxTrays.get(tray);
}
private String findTray(Set<String> trayOptions, String traySelection) {
Pattern exactPattern = Pattern.compile("\\b" + Pattern.quote(traySelection) + "\\b", Pattern.CASE_INSENSITIVE);
Pattern fuzzyPattern = Pattern.compile("\\b.*?[" + Pattern.quote(traySelection) + "]+.*?\\b", Pattern.CASE_INSENSITIVE);
String bestFit = null;
Integer fuzzyFitDelta = null;
for(String option : trayOptions) {
Matcher exactly = exactPattern.matcher(option.trim());
Matcher fuzzily = fuzzyPattern.matcher(option.trim());
if (exactly.find()) {
bestFit = option;
break;
}
while(fuzzily.find()) {
//look for as close to exact match as possible
int delta = Math.abs(fuzzily.group().length() - traySelection.length());
if (fuzzyFitDelta == null || delta < fuzzyFitDelta) {
fuzzyFitDelta = delta;
bestFit = option;
}
}
}
return bestFit;
}
}

View File

@@ -0,0 +1,39 @@
package qz.printer.action;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import qz.printer.PrintOptions;
import qz.printer.PrintOutput;
import qz.utils.PrintingUtilities;
import javax.print.PrintException;
import java.awt.print.PrinterException;
public interface PrintProcessor {
PrintingUtilities.Format getFormat();
/**
* Used to parse information passed from the web API for printing.
*
* @param printData JSON Array of printer data
* @param options Printing options to use for the print job
*/
void parseData(JSONArray printData, PrintOptions options) throws JSONException, UnsupportedOperationException;
/**
* Used to setup and send documents to the specified printing {@code service}.
*
* @param output Destination used for printing
* @param options Printing options to use for the print job
*/
void print(PrintOutput output, PrintOptions options) throws PrintException, PrinterException;
/**
* Reset a processor back to it's initial state.
*/
void cleanup();
}

View File

@@ -0,0 +1,530 @@
/**
* @author Tres Finocchiaro
*
* Copyright (C) 2016 Tres Finocchiaro, QZ Industries, LLC
*
* LGPL 2.1 This is free software. This software and source code are released under
* the "LGPL 2.1 License". A copy of this license should be distributed with
* this software. http://www.gnu.org/licenses/lgpl-2.1.html
*/
package qz.printer.action;
import com.ibm.icu.text.ArabicShapingException;
import org.apache.commons.lang3.StringUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.common.ByteArrayBuilder;
import qz.common.Constants;
import qz.exception.NullCommandException;
import qz.exception.NullPrintServiceException;
import qz.printer.action.raw.ImageWrapper;
import qz.printer.action.raw.LanguageType;
import qz.printer.PrintOptions;
import qz.printer.PrintOutput;
import qz.printer.action.html.WebApp;
import qz.printer.action.html.WebAppModel;
import qz.printer.info.NativePrinter;
import qz.printer.status.CupsUtils;
import qz.utils.*;
import javax.imageio.ImageIO;
import javax.print.*;
import javax.print.attribute.HashPrintRequestAttributeSet;
import javax.print.attribute.PrintRequestAttributeSet;
import javax.print.attribute.standard.JobName;
import javax.print.event.PrintJobEvent;
import javax.print.event.PrintJobListener;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.Socket;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Sends raw data to the printer, overriding your operating system's print
* driver. Most useful for printers such as zebra card or barcode printers.
*
* @author A. Tres Finocchiaro
*/
public class PrintRaw implements PrintProcessor {
private static final Logger log = LogManager.getLogger(PrintRaw.class);
private ByteArrayBuilder commands;
private String destEncoding = null;
private enum Backend {
CUPS_RSS,
CUPS_LPR,
WIN32_WMI
}
public PrintRaw() {
commands = new ByteArrayBuilder();
}
@Override
public PrintingUtilities.Format getFormat() {
return PrintingUtilities.Format.COMMAND;
}
private byte[] getBytes(String str, String destEncoding) throws ArabicShapingException, IOException {
switch(destEncoding.toLowerCase(Locale.ENGLISH)) {
case "ibm864":
case "cp864":
case "csibm864":
case "864":
case "ibm-864":
return ArabicConversionUtilities.convertToIBM864(str);
default:
return str.getBytes(destEncoding);
}
}
@Override
public void parseData(JSONArray printData, PrintOptions options) throws JSONException, UnsupportedOperationException {
for(int i = 0; i < printData.length(); i++) {
JSONObject data = printData.optJSONObject(i);
if (data == null) {
data = new JSONObject();
data.put("data", printData.getString(i));
}
String cmd = data.getString("data");
JSONObject opt = data.optJSONObject("options");
if (opt == null) { opt = new JSONObject(); }
PrintingUtilities.Format format = PrintingUtilities.Format.valueOf(data.optString("format", "COMMAND").toUpperCase(Locale.ENGLISH));
PrintingUtilities.Flavor flavor = PrintingUtilities.Flavor.parse(data, PrintingUtilities.Flavor.PLAIN);
PrintOptions.Raw rawOpts = options.getRawOptions();
PrintOptions.Pixel pxlOpts = options.getPixelOptions();
destEncoding = rawOpts.getDestEncoding();
if (destEncoding == null || destEncoding.isEmpty()) { destEncoding = Charset.defaultCharset().name(); }
try {
switch(format) {
case HTML:
commands.append(getHtmlWrapper(cmd, opt, flavor, rawOpts, pxlOpts).getImageCommand(opt));
break;
case IMAGE:
commands.append(getImageWrapper(cmd, opt, flavor, rawOpts, pxlOpts).getImageCommand(opt));
break;
case PDF:
commands.append(getPdfWrapper(cmd, opt, flavor, rawOpts, pxlOpts).getImageCommand(opt));
break;
case COMMAND:
default:
switch(flavor) {
case PLAIN:
commands.append(getBytes(cmd, destEncoding));
break;
default:
commands.append(seekConversion(flavor.read(cmd, opt.optString("xmlTag", null)), rawOpts));
}
break;
}
}
catch(Exception e) {
throw new UnsupportedOperationException(String.format("Cannot parse (%s)%s into a raw %s command: %s", flavor, data.getString("data"), format, e.getLocalizedMessage()), e);
}
}
}
private byte[] seekConversion(byte[] rawBytes, PrintOptions.Raw rawOpts) {
if (rawOpts.getSrcEncoding() != null) {
if(rawOpts.getSrcEncoding().equals(rawOpts.getDestEncoding()) || rawOpts.getDestEncoding() == null) {
log.warn("Provided srcEncoding and destEncoding are the same, skipping");
} else {
try {
String rawConvert = new String(rawBytes, rawOpts.getSrcEncoding());
return rawConvert.getBytes(rawOpts.getDestEncoding());
}
catch(UnsupportedEncodingException e) {
throw new UnsupportedOperationException(e);
}
}
}
return rawBytes;
}
private ImageWrapper getImageWrapper(String data, JSONObject opt, PrintingUtilities.Flavor flavor, PrintOptions.Raw rawOpts, PrintOptions.Pixel pxlOpts) throws IOException {
BufferedImage bi;
// 2.0 compat
if (data.startsWith("data:image/") && data.contains(";base64,")) {
String[] parts = data.split(";base64,");
data = parts[parts.length - 1];
flavor = PrintingUtilities.Flavor.BASE64;
}
switch(flavor) {
case PLAIN:
// There's really no such thing as a 'PLAIN' image, assume it's a URL
case FILE:
bi = ImageIO.read(ConnectionUtilities.getInputStream(data, true));
break;
default:
bi = ImageIO.read(new ByteArrayInputStream(seekConversion(flavor.read(data), rawOpts)));
}
return getWrapper(bi, opt, pxlOpts);
}
private ImageWrapper getPdfWrapper(String data, JSONObject opt, PrintingUtilities.Flavor flavor, PrintOptions.Raw rawOpts, PrintOptions.Pixel pxlOpts) throws IOException {
PDDocument doc;
switch(flavor) {
case PLAIN:
// There's really no such thing as a 'PLAIN' PDF, assume it's a URL
case FILE:
doc = PDDocument.load(ConnectionUtilities.getInputStream(data, true));
break;
default:
doc = PDDocument.load(new ByteArrayInputStream(seekConversion(flavor.read(data), rawOpts)));
}
double scale;
PDRectangle rect = doc.getPage(0).getBBox();
double pw = opt.optDouble("pageWidth", 0), ph = opt.optDouble("pageHeight", 0);
if (ph <= 0 || (pw > 0 && (rect.getWidth() / rect.getHeight()) >= (pw / ph))) {
scale = pw / rect.getWidth();
} else {
scale = ph / rect.getHeight();
}
if (scale <= 0) { scale = 1.0; }
BufferedImage bi = new PDFRenderer(doc).renderImage(0, (float)scale);
return getWrapper(bi, opt, pxlOpts);
}
private ImageWrapper getHtmlWrapper(String data, JSONObject opt, PrintingUtilities.Flavor flavor, PrintOptions.Raw rawOpts, PrintOptions.Pixel pxlOpts) throws IOException {
switch(flavor) {
case FILE:
case PLAIN:
// We'll toggle between 'plain' and 'file' when we construct WebAppModel
break;
default:
data = new String(seekConversion(flavor.read(data), rawOpts), destEncoding);
}
double density = (pxlOpts.getDensity() * pxlOpts.getUnits().as1Inch());
if (density <= 1) {
density = LanguageType.getType(opt.optString("language")).getDefaultDensity();
}
double pageZoom = density / 72.0;
double pageWidth = opt.optInt("pageWidth") / density * 72;
double pageHeight = opt.optInt("pageHeight") / density * 72;
BufferedImage bi;
WebAppModel model = new WebAppModel(data, (flavor != PrintingUtilities.Flavor.FILE), pageWidth, pageHeight, false, pageZoom);
try {
WebApp.initialize(); //starts if not already started
bi = WebApp.raster(model);
// down scale back from web density
double scaleFactor = opt.optDouble("pageWidth", 0) / bi.getWidth();
BufferedImage scaled = new BufferedImage((int)(bi.getWidth() * scaleFactor), (int)(bi.getHeight() * scaleFactor), BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = scaled.createGraphics();
g2d.drawImage(bi, 0, 0, (int)(bi.getWidth() * scaleFactor), (int)(bi.getHeight() * scaleFactor), null);
g2d.dispose();
bi = scaled;
}
catch(Throwable t) {
if (model.getZoom() > 1 && t instanceof IllegalArgumentException) {
//probably a unrecognized image loader error, try at default zoom
try {
log.warn("Capture failed with increased zoom, attempting with default value");
model.setZoom(1);
bi = WebApp.raster(model);
}
catch(Throwable tt) {
log.error("Failed to capture html raster");
throw new IOException(tt);
}
} else {
log.error("Failed to capture html raster");
throw new IOException(t);
}
}
return getWrapper(bi, opt, pxlOpts);
}
private ImageWrapper getWrapper(BufferedImage img, JSONObject opt, PrintOptions.Pixel pxlOpts) throws IOException {
if(img == null) {
throw new IOException("Image provided is empty or null and cannot be converted.");
}
// Rotate image using orientation or rotation before sending to ImageWrapper
if (pxlOpts.getOrientation() != null && pxlOpts.getOrientation() != PrintOptions.Orientation.PORTRAIT) {
img = PrintImage.rotate(img, pxlOpts.getOrientation().getDegreesRot(), pxlOpts.getDithering(), pxlOpts.getInterpolation());
} else if (pxlOpts.getRotation() % 360 != 0) {
img = PrintImage.rotate(img, pxlOpts.getRotation(), pxlOpts.getDithering(), pxlOpts.getInterpolation());
}
ImageWrapper iw = new ImageWrapper(img, LanguageType.getType(opt.optString("language")));
iw.setCharset(Charset.forName(destEncoding));
//ESC/POS only
int density = opt.optInt("dotDensity", -1);
if (density == -1) {
String dStr = opt.optString("dotDensity", null);
if (dStr != null && !dStr.isEmpty()) {
switch(dStr.toLowerCase(Locale.ENGLISH)) {
case "single": density = 32; break;
case "double": density = 33; break;
case "triple": density = 39; break;
// negative: legacy mode
case "single-legacy": density = -32; break;
case "double-legacy": density = -33; break;
}
} else {
density = 32; //default
}
}
iw.setDotDensity(density);
//EPL only
iw.setxPos(opt.optInt("x", 0));
iw.setyPos(opt.optInt("y", 0));
// PGL only
iw.setLogoId(opt.optString("logoId", ""));
iw.setIgpDots(opt.optBoolean("igpDots", false));
return iw;
}
@Override
public void print(PrintOutput output, PrintOptions options) throws PrintException {
PrintOptions.Raw rawOpts = options.getRawOptions();
List<ByteArrayBuilder> pages;
if (rawOpts.getSpoolSize() > 0 && rawOpts.getSpoolEnd() != null && !rawOpts.getSpoolEnd().isEmpty()) {
try {
pages = ByteUtilities.splitByteArray(commands.getByteArray(), rawOpts.getSpoolEnd().getBytes(destEncoding), rawOpts.getSpoolSize());
}
catch(UnsupportedEncodingException e) {
throw new PrintException(e);
}
} else {
pages = new ArrayList<>();
pages.add(commands);
}
List<File> tempFiles = null;
for(int i = 0; i < rawOpts.getCopies(); i++) {
for(int j = 0; j < pages.size(); j++) {
ByteArrayBuilder bab = pages.get(j);
try {
if (output.isSetHost()) {
printToHost(output.getHost(), output.getPort(), bab.getByteArray());
} else if (output.isSetFile()) {
printToFile(output.getFile(), bab.getByteArray(), true);
} else {
if (rawOpts.isForceRaw()) {
if(tempFiles == null) {
tempFiles = new ArrayList<>(pages.size());
}
File tempFile;
if(tempFiles.size() <= j) {
tempFile = File.createTempFile("qz_raw_", null);
tempFiles.add(j, tempFile);
printToFile(tempFile, bab.getByteArray(), false);
} else {
tempFile = tempFiles.get(j);
}
if(SystemUtilities.isWindows()) {
// Placeholder only; not yet supported
printToBackend(output.getNativePrinter(), tempFile, Backend.WIN32_WMI);
} else {
// Try CUPS backend first, fallback to LPR
printToBackend(output.getNativePrinter(), tempFile, Backend.CUPS_RSS, Backend.CUPS_LPR);
}
} else {
printToPrinter(output.getPrintService(), bab.getByteArray(), rawOpts);
}
}
}
catch(IOException e) {
cleanupTempFiles(rawOpts.isRetainTemp(), tempFiles);
throw new PrintException(e);
}
}
}
cleanupTempFiles(rawOpts.isRetainTemp(), tempFiles);
}
private void cleanupTempFiles(boolean retainTemp, List<File> tempFiles) {
if(tempFiles != null) {
if (!retainTemp) {
for(File tempFile : tempFiles) {
if(tempFile != null) {
if(!tempFile.delete()) {
tempFile.deleteOnExit();
}
}
}
} else {
log.warn("Temp file(s) retained: {}", Arrays.toString(tempFiles.toArray()));
}
}
}
/**
* A brute-force, however surprisingly elegant way to send a file to a networked printer.
* <p/>
* Please note that this will completely bypass the Print Spooler,
* so the Operating System will have absolutely no printer information.
* This is printing "blind".
*/
private void printToHost(String host, int port, byte[] cmds) throws IOException {
log.debug("Printing to host {}:{}", host, port);
//throws any exception and auto-closes socket and stream
try(Socket socket = new Socket(host, port); DataOutputStream out = new DataOutputStream(socket.getOutputStream())) {
out.write(cmds);
}
}
/**
* Writes the raw commands directly to a file.
*
* @param file File to be written
*/
private void printToFile(File file, byte[] cmds, boolean locationRestricted) throws IOException {
if(file == null) throw new IOException("No file specified");
if(locationRestricted && !PrefsSearch.getBoolean(ArgValue.SECURITY_PRINT_TOFILE)) {
log.error("Printing to file '{}' is not permitted. Configure property '{}' to modify this behavior.",
file, ArgValue.SECURITY_PRINT_TOFILE.getMatch());
throw new IOException(String.format("Printing to file '%s' is not permitted", file));
}
log.debug("Printing to file: {}", file.getName());
//throws any exception and auto-closes stream
try(OutputStream out = new FileOutputStream(file)) {
out.write(cmds);
}
}
/**
* Constructs a {@code SimpleDoc} with the {@code commands} byte array.
*/
private void printToPrinter(PrintService service, byte[] cmds, PrintOptions.Raw rawOpts) throws PrintException {
if (service == null) { throw new NullPrintServiceException("Service cannot be null"); }
if (cmds == null || cmds.length == 0) { throw new NullCommandException("No commands found to send to the printer"); }
SimpleDoc doc = new SimpleDoc(cmds, DocFlavor.BYTE_ARRAY.AUTOSENSE, null);
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
attributes.add(new JobName(rawOpts.getJobName(Constants.RAW_PRINT), Locale.getDefault()));
DocPrintJob printJob = service.createPrintJob();
waitForPrint(printJob, doc, attributes);
}
protected void waitForPrint(DocPrintJob printJob, Doc doc, PrintRequestAttributeSet attributes) throws PrintException {
final AtomicBoolean finished = new AtomicBoolean(false);
printJob.addPrintJobListener(new PrintJobListener() {
@Override
public void printDataTransferCompleted(PrintJobEvent printJobEvent) {
log.debug("{}", printJobEvent);
finished.set(true);
}
@Override
public void printJobCompleted(PrintJobEvent printJobEvent) {
log.debug("{}", printJobEvent);
finished.set(true);
}
@Override
public void printJobFailed(PrintJobEvent printJobEvent) {
log.error("{}", printJobEvent);
finished.set(true);
}
@Override
public void printJobCanceled(PrintJobEvent printJobEvent) {
log.warn("{}", printJobEvent);
finished.set(true);
}
@Override
public void printJobNoMoreEvents(PrintJobEvent printJobEvent) {
log.debug("{}", printJobEvent);
finished.set(true);
}
@Override
public void printJobRequiresAttention(PrintJobEvent printJobEvent) {
log.info("{}", printJobEvent);
}
});
log.trace("Sending print job to printer");
printJob.print(doc, attributes);
while(!finished.get()) {
try { Thread.sleep(100); } catch(Exception ignore) {}
}
log.trace("Print job received by printer");
}
/**
* Direct/backend printing modes for forced raw printing
*/
public void printToBackend(NativePrinter printer, File tempFile, Backend... backends) throws IOException, PrintException {
boolean success = false;
for(Backend backend : backends) {
switch(backend) {
case CUPS_LPR:
// Use command line "lp" on Linux, BSD, Solaris, OSX, etc.
String[] lpCmd = new String[] {"lp", "-d", printer.getPrinterId(), "-o", "raw", tempFile.getAbsolutePath()};
if (!(success = ShellUtilities.execute(lpCmd))) {
log.debug(StringUtils.join(lpCmd, ' '));
}
break;
case CUPS_RSS:
// Submit job via cupsDoRequest(...) via JNA against localhost:631\
success = CupsUtils.sendRawFile(printer, tempFile);
break;
case WIN32_WMI:
default:
throw new UnsupportedOperationException("Raw backend \"" + backend + "\" is not yet supported.");
}
if(success) {
break;
}
}
if (!success) {
throw new PrintException("Forced raw printing failed");
}
}
@Override
public void cleanup() {
commands.clear();
destEncoding = null;
}
}

View File

@@ -0,0 +1,44 @@
package qz.printer.action;
import org.apache.commons.pool2.KeyedPooledObjectFactory;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import qz.utils.PrintingUtilities;
public class ProcessorFactory implements KeyedPooledObjectFactory<PrintingUtilities.Format,PrintProcessor> {
@Override
public PooledObject<PrintProcessor> makeObject(PrintingUtilities.Format key) throws Exception {
PrintProcessor processor;
switch(key) {
case HTML: processor = new PrintHTML(); break;
case IMAGE: processor = new PrintImage(); break;
case PDF: processor = new PrintPDF(); break;
case DIRECT: processor = new PrintDirect(); break;
case COMMAND: default: processor = new PrintRaw(); break;
}
return new DefaultPooledObject<>(processor);
}
@Override
public boolean validateObject(PrintingUtilities.Format key, PooledObject<PrintProcessor> p) {
return true; //no-op
}
@Override
public void activateObject(PrintingUtilities.Format key, PooledObject<PrintProcessor> p) throws Exception {
//no-op
}
@Override
public void passivateObject(PrintingUtilities.Format key, PooledObject<PrintProcessor> p) throws Exception {
p.getObject().cleanup();
}
@Override
public void destroyObject(PrintingUtilities.Format key, PooledObject<PrintProcessor> p) throws Exception {
//no-op
}
}

View File

@@ -0,0 +1,512 @@
package qz.printer.action.html;
import com.github.zafarkhaja.semver.Version;
import com.sun.javafx.tk.TKPulseListener;
import com.sun.javafx.tk.Toolkit;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.concurrent.Worker;
import javafx.embed.swing.SwingFXUtils;
import javafx.print.PageLayout;
import javafx.print.PrinterJob;
import javafx.scene.Scene;
import javafx.scene.shape.Rectangle;
import javafx.scene.transform.Scale;
import javafx.scene.transform.Transform;
import javafx.scene.transform.Translate;
import javafx.scene.web.WebView;
import javafx.stage.Stage;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import qz.common.Constants;
import qz.utils.SystemUtilities;
import qz.ws.PrintSocketServer;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.IntPredicate;
/**
* JavaFX container for taking HTML snapshots.
* Used by PrintHTML to generate printable images.
* <p/>
* Do not use constructor (used by JavaFX), instead call {@code WebApp.initialize()}
*/
public class WebApp extends Application {
private static final Logger log = LogManager.getLogger(WebApp.class);
private static WebApp instance = null;
private static Version webkitVersion = null;
private static int CAPTURE_FRAMES = 2;
private static int VECTOR_FRAMES = 1;
private static Stage stage;
private static WebView webView;
private static double pageWidth;
private static double pageHeight;
private static double pageZoom;
private static boolean raster;
private static boolean headless;
private static CountDownLatch startupLatch;
private static CountDownLatch captureLatch;
private static IntPredicate printAction;
private static final AtomicReference<Throwable> thrown = new AtomicReference<>();
// JDK-8283686: Printing WebView may results in empty page
private static final Version JDK_8283686_START = Version.valueOf(/* WebKit */ "609.1.0");
private static final Version JDK_8283686_END = Version.valueOf(/* WebKit */ "612.1.0");
private static final int JDK_8283686_VECTOR_FRAMES = 30;
//listens for a Succeeded state to activate image capture
private static ChangeListener<Worker.State> stateListener = (ov, oldState, newState) -> {
log.trace("New state: {} > {}", oldState, newState);
// Cancelled should probably throw exception listener, but does not
if (newState == Worker.State.CANCELLED) {
// This can happen for file downloads, e.g. "response-content-disposition=attachment"
// See https://github.com/qzind/tray/issues/1183
unlatch(new IOException("Page load was cancelled for an unknown reason"));
}
if (newState == Worker.State.SUCCEEDED) {
boolean hasBody = (boolean)webView.getEngine().executeScript("document.body != null");
if (!hasBody) {
log.warn("Loaded page has no body - likely a redirect, skipping state");
return;
}
//ensure html tag doesn't use scrollbars, clipping page instead
Document doc = webView.getEngine().getDocument();
NodeList tags = doc.getElementsByTagName("html");
if (tags != null && tags.getLength() > 0) {
Node base = tags.item(0);
Attr applied = (Attr)base.getAttributes().getNamedItem("style");
if (applied == null) {
applied = doc.createAttribute("style");
}
applied.setValue(applied.getValue() + "; overflow: hidden;");
base.getAttributes().setNamedItem(applied);
}
//width was resized earlier (for responsive html), then calculate the best fit height
// FIXME: Should only be needed when height is unknown but fixes blank vector prints
double fittedHeight = findHeight();
boolean heightNeeded = pageHeight <= 0;
if (heightNeeded) {
pageHeight = fittedHeight;
}
// find and set page zoom for increased quality
double usableZoom = calculateSupportedZoom(pageWidth, pageHeight);
if (usableZoom < pageZoom) {
log.warn("Zoom level {} decreased to {} due to physical memory limitations", pageZoom, usableZoom);
pageZoom = usableZoom;
}
webView.setZoom(pageZoom);
log.trace("Zooming in by x{} for increased quality", pageZoom);
adjustSize(pageWidth * pageZoom, pageHeight * pageZoom);
//need to check for height again as resizing can cause partial results
if (heightNeeded) {
fittedHeight = findHeight();
if (fittedHeight != pageHeight) {
adjustSize(pageWidth * pageZoom, fittedHeight * pageZoom);
}
}
log.trace("Set HTML page height to {}", pageHeight);
autosize(webView);
Platform.runLater(() -> new AnimationTimer() {
int frames = 0;
@Override
public void handle(long l) {
if (printAction.test(++frames)) {
stop();
}
}
}.start());
}
};
//listens for load progress
private static ChangeListener<Number> workDoneListener = (ov, oldWork, newWork) -> log.trace("Done: {} > {}", oldWork, newWork);
private static ChangeListener<String> msgListener = (ov, oldMsg, newMsg) -> log.trace("New status: {}", newMsg);
//listens for failures
private static ChangeListener<Throwable> exceptListener = (obs, oldExc, newExc) -> {
if (newExc != null) { unlatch(newExc); }
};
/** Called by JavaFX thread */
public WebApp() {
instance = this;
}
/** Starts JavaFX thread if not already running */
public static synchronized void initialize() throws IOException {
if (instance == null) {
startupLatch = new CountDownLatch(1);
// For JDK8 compat
headless = false;
// JDK11+ depends bundled javafx
if (Constants.JAVA_VERSION.getMajorVersion() >= 11) {
// Monocle default for unit tests
boolean useMonocle = true;
if (PrintSocketServer.getTrayManager() != null) {
// Honor user monocle override
useMonocle = PrintSocketServer.getTrayManager().isMonoclePreferred();
// Trust TrayManager's headless detection
headless = PrintSocketServer.getTrayManager().isHeadless();
} else {
// Fallback for JDK11+
headless = true;
}
if (useMonocle && SystemUtilities.hasMonocle()) {
log.trace("Initializing monocle platform");
System.setProperty("javafx.platform", "monocle");
// Don't set glass.platform on Linux per https://github.com/qzind/tray/issues/702
switch(SystemUtilities.getOs()) {
case WINDOWS:
case MAC:
System.setProperty("glass.platform", "Monocle");
break;
default:
// don't set "glass.platform"
}
//software rendering required headless environments
if (headless) {
System.setProperty("prism.order", "sw");
}
} else {
log.warn("Monocle platform will not be used");
}
}
new Thread(() -> Application.launch(WebApp.class)).start();
}
if (startupLatch.getCount() > 0) {
try {
log.trace("Waiting for JavaFX..");
if (!startupLatch.await(60, TimeUnit.SECONDS)) {
throw new IOException("JavaFX did not start");
} else {
log.trace("Running a test snapshot to size the stage...");
try {
raster(new WebAppModel("<h1>startup</h1>", true, 0, 0, true, 2));
log.trace("JFX initialized successfully");
}
catch(Throwable t) {
throw new IOException(t);
}
}
}
catch(InterruptedException ignore) {}
}
}
@Override
public void start(Stage st) throws Exception {
startupLatch.countDown();
log.debug("Started JavaFX");
webView = new WebView();
// JDK-8283686: Printing WebView may results in empty page
// See also https://github.com/qzind/tray/issues/778
if(getWebkitVersion() == null ||
(getWebkitVersion().greaterThan(JDK_8283686_START) &&
getWebkitVersion().lessThan(JDK_8283686_END))) {
VECTOR_FRAMES = JDK_8283686_VECTOR_FRAMES; // Additional pulses needed for vector graphics
}
st.setScene(new Scene(webView));
stage = st;
stage.setWidth(1);
stage.setHeight(1);
Worker<Void> worker = webView.getEngine().getLoadWorker();
worker.stateProperty().addListener(stateListener);
worker.workDoneProperty().addListener(workDoneListener);
worker.exceptionProperty().addListener(exceptListener);
worker.messageProperty().addListener(msgListener);
//prevents JavaFX from shutting down when hiding window
Platform.setImplicitExit(false);
}
/**
* Prints the loaded source specified in the passed {@code model}.
*
* @param job A setup JavaFx {@code PrinterJob}
* @param model The model specifying the web page parameters
* @throws Throwable JavaFx will throw a generic {@code Throwable} class for any issues
*/
public static synchronized void print(final PrinterJob job, final WebAppModel model) throws Throwable {
model.setZoom(1); //vector prints do not need to use zoom
raster = false;
load(model, (int frames) -> {
if(frames == VECTOR_FRAMES) {
try {
double printScale = 72d / 96d;
webView.getTransforms().add(new Scale(printScale, printScale));
PageLayout layout = job.getJobSettings().getPageLayout();
if (model.isScaled()) {
double viewWidth = webView.getWidth() * printScale;
double viewHeight = webView.getHeight() * printScale;
double scale;
if ((viewWidth / viewHeight) >= (layout.getPrintableWidth() / layout.getPrintableHeight())) {
scale = (layout.getPrintableWidth() / viewWidth);
} else {
scale = (layout.getPrintableHeight() / viewHeight);
}
webView.getTransforms().add(new Scale(scale, scale));
}
Platform.runLater(() -> {
double useScale = 1;
for(Transform t : webView.getTransforms()) {
if (t instanceof Scale) { useScale *= ((Scale)t).getX(); }
}
PageLayout page = job.getJobSettings().getPageLayout();
Rectangle printBounds = new Rectangle(0, 0, page.getPrintableWidth(), page.getPrintableHeight());
log.debug("Paper area: {},{}:{},{}", (int)page.getLeftMargin(), (int)page.getTopMargin(),
(int)page.getPrintableWidth(), (int)page.getPrintableHeight());
Translate activePage = new Translate();
webView.getTransforms().add(activePage);
int columnsNeed = Math.max(1, (int)Math.ceil(webView.getWidth() / printBounds.getWidth() * useScale - 0.1));
int rowsNeed = Math.max(1, (int)Math.ceil(webView.getHeight() / printBounds.getHeight() * useScale - 0.1));
log.debug("Document will be printed across {} pages", columnsNeed * rowsNeed);
try {
for(int row = 0; row < rowsNeed; row++) {
for(int col = 0; col < columnsNeed; col++) {
activePage.setX((-col * printBounds.getWidth()) / useScale);
activePage.setY((-row * printBounds.getHeight()) / useScale);
job.printPage(webView);
}
}
unlatch(null);
}
catch(Exception e) {
unlatch(e);
}
finally {
//reset state
webView.getTransforms().clear();
}
});
}
catch(Exception e) { unlatch(e); }
}
return frames >= VECTOR_FRAMES;
});
log.trace("Waiting on print..");
captureLatch.await(); //released when unlatch is called
if (thrown.get() != null) { throw thrown.get(); }
}
public static synchronized BufferedImage raster(final WebAppModel model) throws Throwable {
AtomicReference<BufferedImage> capture = new AtomicReference<>();
//ensure JavaFX has started before we run
if (startupLatch.getCount() > 0) {
throw new IOException("JavaFX has not been started");
}
//raster still needs to show stage for valid capture
Platform.runLater(() -> {
stage.show();
stage.toBack();
});
raster = true;
load(model, (int frames) -> {
if (frames == CAPTURE_FRAMES) {
log.debug("Attempting image capture");
Toolkit.getToolkit().addPostSceneTkPulseListener(new TKPulseListener() {
@Override
public void pulse() {
try {
// TODO: Revert to Callback once JDK-8244588/SUPQZ-5 is avail (JDK11+ only)
capture.set(SwingFXUtils.fromFXImage(webView.snapshot(null, null), null));
unlatch(null);
}
catch(Exception e) {
unlatch(e);
}
finally {
Toolkit.getToolkit().removePostSceneTkPulseListener(this);
}
}
});
Toolkit.getToolkit().requestNextPulse();
}
return frames >= CAPTURE_FRAMES;
});
log.trace("Waiting on capture..");
captureLatch.await(); //released when unlatch is called
if (thrown.get() != null) { throw thrown.get(); }
return capture.get();
}
/**
* Prints the loaded source specified in the passed {@code model}.
*
* @param model The model specifying the web page parameters.
* @param action EventHandler that will be ran when the WebView completes loading.
*/
private static synchronized void load(WebAppModel model, IntPredicate action) {
captureLatch = new CountDownLatch(1);
thrown.set(null);
Platform.runLater(() -> {
//zoom should only be factored on raster prints
pageZoom = model.getZoom();
pageWidth = model.getWebWidth();
pageHeight = model.getWebHeight();
log.trace("Setting starting size {}:{}", pageWidth, pageHeight);
adjustSize(pageWidth * pageZoom, pageHeight * pageZoom);
if (pageHeight == 0) {
webView.setMinHeight(1);
webView.setPrefHeight(1);
webView.setMaxHeight(1);
}
autosize(webView);
printAction = action;
if (model.isPlainText()) {
webView.getEngine().loadContent(model.getSource(), "text/html");
} else {
webView.getEngine().load(model.getSource());
}
});
}
private static double findHeight() {
String heightText = webView.getEngine().executeScript("Math.max(document.body.offsetHeight, document.body.scrollHeight)").toString();
return Double.parseDouble(heightText);
}
private static void adjustSize(double toWidth, double toHeight) {
webView.setMinSize(toWidth, toHeight);
webView.setPrefSize(toWidth, toHeight);
webView.setMaxSize(toWidth, toHeight);
}
/**
* Fix blank page after autosize is called
*/
public static void autosize(WebView webView) {
webView.autosize();
if (!raster) {
// Call updatePeer; fixes a bug with webView resizing
// Can be avoided by calling stage.show() but breaks headless environments
// See: https://github.com/qzind/tray/issues/513
String[] methods = {"impl_updatePeer" /*jfx8*/, "doUpdatePeer" /*jfx11*/};
try {
for(Method m : webView.getClass().getDeclaredMethods()) {
for(String method : methods) {
if (m.getName().equals(method)) {
m.setAccessible(true);
m.invoke(webView);
return;
}
}
}
}
catch(SecurityException | ReflectiveOperationException e) {
log.warn("Unable to update peer; Blank pages may occur.", e);
}
}
}
private static double calculateSupportedZoom(double width, double height) {
long memory = Runtime.getRuntime().maxMemory();
int allowance = (memory / 1048576L) > 1024? 3:2;
if (headless) { allowance--; }
long availSpace = memory << allowance;
// Memory needed for print is roughly estimated as
// (width * height) [pixels needed] * (pageZoom * 72d) [print density used] * 3 [rgb channels]
return Math.sqrt(availSpace / ((width * height) * (pageZoom * 72d) * 3));
}
/**
* Final cleanup when no longer capturing
*/
public static void unlatch(Throwable t) {
if (t != null) {
thrown.set(t);
}
captureLatch.countDown();
stage.hide();
}
public static Version getWebkitVersion() {
if(webkitVersion == null) {
if(webView != null) {
String userAgent = webView.getEngine().getUserAgent();
String[] parts = userAgent.split("WebKit/");
if (parts.length > 1) {
String[] split = parts[1].split(" ");
if (split.length > 0) {
try {
webkitVersion = Version.valueOf(split[0]);
log.info("WebKit version {} detected", webkitVersion);
} catch(Exception ignore) {}
}
}
if(webkitVersion == null) {
log.warn("WebKit version couldn't be parsed from UserAgent: {}", userAgent);
}
} else {
log.warn("Can't get WebKit version, JavaFX hasn't started yet.");
}
}
return webkitVersion;
}
}

View File

@@ -0,0 +1,81 @@
package qz.printer.action.html;
public class WebAppModel {
private String source;
private boolean plainText;
private double width, webWidth;
private double height, webHeight;
private boolean isScaled;
private double zoom;
public WebAppModel(String source, boolean plainText, double width, double height, boolean isScaled, double zoom) {
this.source = source;
this.plainText = plainText;
this.width = width;
this.height = height;
this.webWidth = width * (96d / 72d);
this.webHeight = height * (96d / 72d);
this.isScaled = isScaled;
this.zoom = zoom;
}
public String getSource() {
return source;
}
public void setSource(String source) {
this.source = source;
}
public boolean isPlainText() {
return plainText;
}
public void setPlainText(boolean plainText) {
this.plainText = plainText;
}
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
this.webWidth = width * (96d / 72d);
}
public double getHeight() {
return height;
}
public void setHeight(double height) {
this.height = height;
this.webHeight = height * (96d / 72d);
}
public double getWebWidth() {
return webWidth;
}
public double getWebHeight() {
return webHeight;
}
public boolean isScaled() {
return isScaled;
}
public void setScaled(boolean scaled) {
isScaled = scaled;
}
public double getZoom() {
return zoom;
}
public void setZoom(double zoom) {
this.zoom = zoom;
}
}

View File

@@ -0,0 +1,80 @@
package qz.printer.action.pdf;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.awt.*;
import java.awt.print.Book;
import java.awt.print.PageFormat;
import java.awt.print.Printable;
import java.awt.print.PrinterException;
/**
* Wrapper of the {@code Book} class as a {@code Printable} type,
* since PrinterJob implementations do not seem to handle the {@code Pageable} interface properly.
*/
public class BookBundle extends Book {
private static final Logger log = LogManager.getLogger(BookBundle.class);
private Printable lastPrint;
private int lastStarted;
public BookBundle() {
super();
}
/**
* Wrapper of the wrapper class so that PrinterJob implementations will handle it as proper pageable
*/
public Book wrapAndPresent() {
Book cover = new Book();
for(int i = 0; i < getNumberOfPages(); i++) {
cover.append(new PrintingPress(), getPageFormat(i));
}
return cover;
}
public Book wrapAndPresent(int offset, int length) {
Book coverSubset = new Book();
for(int i = offset; i < offset + length && i < getNumberOfPages(); i++) {
coverSubset.append(new PrintingPress(offset), getPageFormat(i));
}
return coverSubset;
}
/** Printable wrapper to ensure proper reading of multiple documents across spooling */
private class PrintingPress implements Printable {
private int pageOffset;
public PrintingPress() {
this(0);
}
public PrintingPress(int offset) {
pageOffset = offset;
}
@Override
public int print(Graphics g, PageFormat format, int pageIndex) throws PrinterException {
pageIndex += pageOffset;
log.trace("Requested page {} for printing", pageIndex);
if (pageIndex < getNumberOfPages()) {
Printable printable = getPrintable(pageIndex);
if (printable != lastPrint) {
lastPrint = printable;
lastStarted = pageIndex;
}
return printable.print(g, format, pageIndex - lastStarted);
}
return NO_SUCH_PAGE;
}
}
}

Some files were not shown because too many files have changed in this diff Show More